feat(apple): gamepad UI v2 — controller settings + add host, aurora, macOS
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit: Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split along the same seams. The gamepad mode is couch-complete, and now on macOS too (the living-room Mac case), not just iOS/iPadOS: - GamepadSettingsView: a console-style, fully controller-navigable settings screen (X from the launcher) — up/down moves focus, left/right steps values (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a one-line description. Backed by GamepadMenuList, the vertical sibling of GamepadCarousel, and SettingsOptions — the option lists hoisted out of SettingsView statics and shared by the touch, tvOS and gamepad settings. - GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad — field rows open an on-screen controller keyboard (dpad grid, A types, X backspaces, B done); the launcher carousel ends in an Add Host tile, so the dead-end "add one with touch first" empty state is gone. - Launcher polish: contextual hint bar with the pad's real button glyphs, controller name + battery chip, one shared console chrome. - GamepadScreenBackground: an animated aurora (TimelineView-driven drifting blobs in the brand's violet family, breathing radii, slow hue shift, legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a .metal library only bundles reliably in one of the two build systems (SPM vs the xcodeproj's synced folders) these sources compile under. - macOS port: settings/add-host/library present as sized sheets (a macOS sheet takes its content's IDEAL size, and the GeometryReader-driven screens collapsed to nothing), NSScreen-based mode lists, scroll indicators .never (the "always show scroll bars" setting overrides .hidden), tray scrims so scrolled rows dim under the pinned title/hints, extra title clearance, and a PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/ library render-verified live on a real Mac + LAN hosts. - GamepadMenuInput: X button support, and (re)start now snapshots held buttons so a controller handoff press never fires twice (the B that closed the keyboard no longer also cancels the screen underneath). - Cleanups: one "Connection failed" alert in ContentView instead of one per home screen; HostDiscovery.advertises/unsaved shared by both home screens. - host: can_encode_444 stub for the non-Linux/Windows host build (the macOS synthetic-source loopback used by the Swift tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// "+" sheet: name (optional) + address + port → a card in the hosts grid. The first
|
||||
// actual connection runs the trust-on-first-use fingerprint prompt.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddHostSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var address = ""
|
||||
@State private var port = 9777
|
||||
#if os(tvOS)
|
||||
private enum EditField: String, Identifiable {
|
||||
case name, address, port
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@State private var editing: EditField?
|
||||
#endif
|
||||
|
||||
let onAdd: (StoredHost) -> Void
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// No inline text editing on tvOS — Settings-style value rows; pressing one
|
||||
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
|
||||
VStack(spacing: 24) {
|
||||
TVFieldRow(
|
||||
label: "Name", value: name, placeholder: "Optional"
|
||||
) { editing = .name }
|
||||
TVFieldRow(
|
||||
label: "Address", value: address, placeholder: "IP or hostname"
|
||||
) { editing = .address }
|
||||
TVFieldRow(
|
||||
label: "Port", value: String(port), placeholder: ""
|
||||
) { editing = .port }
|
||||
HStack(spacing: 32) {
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Add Host") { add() }
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(60)
|
||||
.navigationTitle("Add Host")
|
||||
.fullScreenCover(item: $editing) { field in
|
||||
switch field {
|
||||
case .name:
|
||||
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
|
||||
name = $0
|
||||
editing = nil
|
||||
}
|
||||
case .address:
|
||||
TVTextEntry(title: "IP or hostname", text: address) {
|
||||
address = $0.trimmingCharacters(in: .whitespaces)
|
||||
editing = nil
|
||||
}
|
||||
case .port:
|
||||
TVTextEntry(
|
||||
title: "Port", text: String(port), keyboardType: .numberPad
|
||||
) {
|
||||
if let value = Int($0), (1...65535).contains(value) {
|
||||
port = value
|
||||
}
|
||||
editing = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
||||
TextField("Address", text: $address, prompt: Text("IP or hostname"))
|
||||
TextField("Port", value: $port, format: .number.grouping(.never))
|
||||
#if os(tvOS)
|
||||
// tvOS floats the label above a non-empty field INSIDE the pill,
|
||||
// shoving the value off-center — the field is always prefilled
|
||||
// here, so drop the label there.
|
||||
.labelsHidden()
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The detent below is sized to fit all 3 rows + the action button exactly, so the
|
||||
// Form must NOT scroll/bounce inside it — lock it. (iOS 16+; safe at iOS 17.)
|
||||
.scrollDisabled(true)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
||||
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
||||
// keeps this compact and centered.
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Add Host") { add() }
|
||||
.glassProminentButtonStyle()
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(16)
|
||||
#else
|
||||
// iOS / iPadOS: NO Cancel — the sheet is dismissed by the drag indicator,
|
||||
// swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled,
|
||||
// so all three are live; if anyone adds it later, restore a Cancel here or there is
|
||||
// no way back out.) A single FULL-WIDTH primary action reads as the one thing to do.
|
||||
// The fill must be on the LABEL, not the Button: .frame(maxWidth:.infinity) on the
|
||||
// Button only widens its hit area and leaves the styled capsule hugging the text —
|
||||
// stretching the label is what makes the glass/bordered pill itself go edge-to-edge.
|
||||
// .controlSize(.large) gives the tall, thumb-friendly height; .defaultAction lets a
|
||||
// hardware keyboard / iPad Return submit.
|
||||
Button { add() } label: {
|
||||
Text("Add Host").frame(maxWidth: .infinity)
|
||||
}
|
||||
.glassProminentButtonStyle()
|
||||
.controlSize(.large)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.padding(16)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
// A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped
|
||||
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
||||
// at. A single fixed detent is enough: the system keeps the content above the keyboard
|
||||
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
|
||||
// centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
|
||||
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||
.presentationDetents([.height(320)])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.frame(width: 380)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
private func add() {
|
||||
onAdd(StoredHost(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(clamping: port)))
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS) — the controller counterpart of
|
||||
// AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address /
|
||||
// port) plus the Add action, navigated with the same vertical focus list as the gamepad settings;
|
||||
// A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end
|
||||
// without touching the screen. Field edits are live (the row shows every keystroke); B closes the
|
||||
// keyboard first, then cancels the screen — the same "back peels one layer" rule as a console UI.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onAdd: (StoredHost) -> Void
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS; the sheet is sized to fit the tray
|
||||
#endif
|
||||
@State private var name = ""
|
||||
@State private var address = ""
|
||||
@State private var port = "9777"
|
||||
@State private var focusID: String?
|
||||
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||
@State private var editing: String?
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
items: rows,
|
||||
focusID: $focusID,
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { dismiss() },
|
||||
isActive: editing == nil
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: 620)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 4) {
|
||||
Text("Add Host")
|
||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 440)
|
||||
}
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
bottomTray
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
// A port can't exceed 5 digits — cap while typing so the row can't grow absurd.
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard tray while editing, the controls legend otherwise.
|
||||
@ViewBuilder private var bottomTray: some View {
|
||||
if let editing {
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
text: editingBinding(editing),
|
||||
allowed: allowedCharacters(editing),
|
||||
onDone: { closeKeyboard() })
|
||||
// Fresh keyboard per field: a touch user can retarget the tray by tapping
|
||||
// another field row, and the keyboard's input wiring captured the previous
|
||||
// binding on appear — new identity forces a rewire to the new field.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
/// rides the cancel action.
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 34, height: 34)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityLabel("Cancel")
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
var value = ""
|
||||
var placeholder = ""
|
||||
var isAction = false
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
[
|
||||
Row(id: "name", label: "Name", value: name, placeholder: "Optional — e.g. Living Room"),
|
||||
Row(id: "address", label: "Address", value: address, placeholder: "IP or hostname"),
|
||||
Row(id: "port", label: "Port", value: port, placeholder: "9777"),
|
||||
Row(id: "add", label: "Add Host", isAction: true),
|
||||
]
|
||||
}
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
if row.isAction {
|
||||
Label("Add Host", systemImage: "plus.circle.fill")
|
||||
.font(.geist(16, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(row.label)
|
||||
.font(.geist(16, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(.white)
|
||||
Spacer(minLength: 12)
|
||||
Text(row.value.isEmpty ? row.placeholder : row.value)
|
||||
.font(.geistFixed(15, .medium))
|
||||
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head) // keep the end of a long address visible while typing
|
||||
if editing == row.id {
|
||||
// The live-edit caret: this row is what the keyboard tray is typing into.
|
||||
Rectangle()
|
||||
.fill(Color.brand)
|
||||
.frame(width: 2, height: 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.white.opacity(focused || editing == row.id ? 0.1 : 0))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.22 : 0),
|
||||
lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func activate(id: String) {
|
||||
switch id {
|
||||
case "add":
|
||||
guard canAdd else {
|
||||
// Not addable yet — jump straight to what's missing instead of a dead press.
|
||||
focusID = "address"
|
||||
openKeyboard("address")
|
||||
return
|
||||
}
|
||||
onAdd(StoredHost(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(port) ?? 9777))
|
||||
dismiss()
|
||||
default:
|
||||
openKeyboard(id)
|
||||
}
|
||||
}
|
||||
|
||||
private var canAdd: Bool {
|
||||
!address.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& UInt16(port).map { $0 > 0 } == true
|
||||
}
|
||||
|
||||
private func openKeyboard(_ id: String) {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = id }
|
||||
}
|
||||
|
||||
private func closeKeyboard() {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
|
||||
}
|
||||
|
||||
private func editingBinding(_ id: String) -> Binding<String> {
|
||||
switch id {
|
||||
case "name": return $name
|
||||
case "port": return $port
|
||||
default: return $address
|
||||
}
|
||||
}
|
||||
|
||||
/// What the keyboard may type per field: a port is digits, an address never contains spaces;
|
||||
/// a name is free-form.
|
||||
private func allowedCharacters(_ id: String) -> CharacterSet? {
|
||||
switch id {
|
||||
case "port": return CharacterSet(charactersIn: "0123456789")
|
||||
case "address": return CharacterSet(charactersIn: " ").inverted
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,270 @@
|
||||
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
|
||||
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
|
||||
// by a controller (iOS/iPadOS/macOS).
|
||||
//
|
||||
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
|
||||
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
||||
// container width, so it's correct in an iPad split view too) lets the first and last item reach the
|
||||
// middle. The CALLER owns each card's look, including its own `.scrollTransition` — this component
|
||||
// deliberately applies none, so a screen can chain the VisualEffect-only transition modifiers without
|
||||
// the generic wrapper here pushing the type-checker onto an overload it can't satisfy.
|
||||
//
|
||||
// Navigation authority: an internal `cursor` (an index), NOT the scroll-position binding, is the
|
||||
// source of truth for where the gamepad is. `.scrollPosition(id:)` is a two-way binding and the
|
||||
// scroll view WRITES intermediate ids into it while a programmatic animation is in flight — so
|
||||
// reading the "current" item back out of it to compute the next one desyncs badly on a fast held
|
||||
// stick (each move reads a lagging value and the cursor stalls before the last item). Instead a move
|
||||
// advances `cursor` synchronously and points the scroll view at `items[cursor]`; scroll read-back is
|
||||
// only allowed to move the cursor when the gamepad hasn't driven recently (i.e. a touch drag).
|
||||
//
|
||||
// Feedback is dual-channel by design: `.sensoryFeedback` ticks the DEVICE Taptic engine (for a
|
||||
// handheld/touch user) and `MenuHaptics` ticks the CONTROLLER (for a couch user holding the pad).
|
||||
// Both fire on a move, on confirm, and — for a non-wrapping list — a duller bump plus a short visual
|
||||
// recoil when a move is refused at either end.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
|
||||
let items: [Item]
|
||||
/// Output only: the carousel WRITES the focused item's id here for the caller's detail panel.
|
||||
/// It is deliberately not what drives the scroll (see the file header).
|
||||
@Binding var selection: Item.ID?
|
||||
/// Every card is laid out at this fixed width so `.viewAligned` snapping + symmetric side
|
||||
/// insets center exactly one at a time.
|
||||
let itemWidth: CGFloat
|
||||
let spacing: CGFloat
|
||||
/// A → activate the centered item.
|
||||
let onActivate: (Item) -> Void
|
||||
/// Y → the screen's secondary action (e.g. open a host's library); nil disables it.
|
||||
var onSecondary: (() -> Void)?
|
||||
/// X → the screen's tertiary action (e.g. open settings); nil disables it.
|
||||
var onTertiary: (() -> Void)?
|
||||
/// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
|
||||
var onBack: (() -> Void)?
|
||||
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
|
||||
var shoulderJump: Int = 0
|
||||
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
|
||||
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
|
||||
/// otherwise poll the SAME controller at once — driving both. The parent sets this false while
|
||||
/// something is presented on top so only the front-most carousel consumes the gamepad.
|
||||
var isActive: Bool = true
|
||||
@ViewBuilder let card: (Item) -> Card
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
|
||||
/// while the gamepad is driving — that's the whole desync fix.
|
||||
@State private var cursor = 0
|
||||
/// The id the scroll view is aligned to — its own two-way `.scrollPosition` state.
|
||||
@State private var scrolledID: Item.ID?
|
||||
/// When the gamepad last moved the cursor; gates scroll read-back so a mid-animation write can't
|
||||
/// drag the cursor backward during a fast held direction.
|
||||
@State private var lastNav = Date.distantPast
|
||||
/// True while a programmatic scroll animation is in flight. `.scrollPosition(id:)` DROPS a new
|
||||
/// write that lands mid-animation — the scroll view stays stuck on the old item even though the
|
||||
/// binding updated — so we never issue one until the previous animation reports complete, then
|
||||
/// `commitScroll` re-targets the current cursor (coalescing a fast burst; see `commitScroll`).
|
||||
@State private var isScrolling = false
|
||||
/// A short horizontal recoil when a move is refused at a list end.
|
||||
@State private var bumpOffset: CGFloat = 0
|
||||
/// `.sensoryFeedback` fires on a change of its trigger; counters request a device tick for the
|
||||
/// confirm and end-stop events (moves trigger on `cursor`).
|
||||
@State private var activateTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
|
||||
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
|
||||
/// (longer than a move animation, so overlapping held-stick moves never let it through).
|
||||
private let navSettle: TimeInterval = 0.4
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(items) { item in
|
||||
card(item)
|
||||
.frame(width: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(item) }
|
||||
}
|
||||
}
|
||||
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollPosition(id: $scrolledID)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden
|
||||
// and paints a scroller across the console strip.
|
||||
.scrollIndicators(.never)
|
||||
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
||||
.safeAreaPadding(.horizontal, inset)
|
||||
.offset(x: bumpOffset)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
.onAppear {
|
||||
reconcile()
|
||||
wire()
|
||||
if isActive { input.start() }
|
||||
}
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
// Hand controller input to/from a screen presented on top (see `isActive`): a covered
|
||||
// carousel stops polling so it can't navigate behind the front-most one.
|
||||
.onChange(of: isActive) { _, active in
|
||||
if active {
|
||||
wire()
|
||||
input.start()
|
||||
} else {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
}
|
||||
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
|
||||
// programmatic scroll is animating (its own intermediate id write-backs would regress the
|
||||
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
|
||||
// — which never sets `isScrolling` — moves the cursor here.
|
||||
.onChange(of: scrolledID) { _, newValue in
|
||||
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
||||
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||
cursor = idx
|
||||
selection = newValue
|
||||
}
|
||||
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
|
||||
// current `items` value (a plain array — unlike an observed object it would otherwise go
|
||||
// stale in the closures stored on `input`).
|
||||
.onChange(of: items.map(\.id)) { _, _ in
|
||||
reconcile()
|
||||
wire()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { activate() }
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
input.onBack = onBack
|
||||
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
let forward = direction == .right || direction == .down
|
||||
step(by: forward ? 1 : -1, clampAtEnds: false)
|
||||
}
|
||||
|
||||
private func shoulder(right: Bool) {
|
||||
step(by: right ? shoulderJump : -shoulderJump, clampAtEnds: true)
|
||||
}
|
||||
|
||||
/// Advance the cursor by `delta`. A single move (`clampAtEnds: false`) that would leave the list
|
||||
/// recoils + bumps; a shoulder jump (`clampAtEnds: true`) lands on the end item, bumping only if
|
||||
/// already there. The cursor is the authority — the scroll view is pointed at it, never read for it.
|
||||
private func step(by delta: Int, clampAtEnds: Bool) {
|
||||
guard !items.isEmpty else { return }
|
||||
var target = cursor + delta
|
||||
if target < 0 || target >= items.count {
|
||||
guard clampAtEnds else { return boundaryBump(forward: delta > 0) }
|
||||
target = min(max(target, 0), items.count - 1)
|
||||
}
|
||||
guard target != cursor else { return boundaryBump(forward: delta > 0) }
|
||||
cursor = target
|
||||
lastNav = Date()
|
||||
haptics.move()
|
||||
selection = items[target].id // text/detail updates immediately; the scroll chases
|
||||
commitScroll()
|
||||
}
|
||||
|
||||
private let scrollAnim: TimeInterval = 0.24
|
||||
/// A hair past `scrollAnim` — long enough that the scroll has actually settled before the next
|
||||
/// write, short enough to stay responsive.
|
||||
private var scrollSettle: TimeInterval { scrollAnim + 0.05 }
|
||||
|
||||
/// Drive the scroll toward the current cursor, one honoured write at a time. `.scrollPosition(id:)`
|
||||
/// DROPS a write that lands while a scroll is still animating, so we issue at most one at a time and
|
||||
/// re-target the LATEST cursor once it settles — coalescing a fast burst (hold OR quick flicks) and
|
||||
/// always converging on the final item, instead of getting stuck on the old card.
|
||||
///
|
||||
/// The settle is timed by a plain timer rather than `withAnimation`'s completion: `scrolledID` is a
|
||||
/// discrete id, not an animatable value, so `withAnimation` has no tracked animation to fire a
|
||||
/// reliable completion against (it can fire early — which is exactly what let quick flicks slip a
|
||||
/// write through mid-scroll and stick). `asyncAfter` always fires, so `isScrolling` can never latch.
|
||||
private func commitScroll() {
|
||||
guard !isScrolling, cursor >= 0, cursor < items.count else { return }
|
||||
let id = items[cursor].id
|
||||
guard scrolledID != id else { return }
|
||||
isScrolling = true
|
||||
withAnimation(.easeOut(duration: scrollAnim)) { scrolledID = id }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + scrollSettle) {
|
||||
MainActor.assumeIsolated {
|
||||
isScrolling = false
|
||||
commitScroll() // the cursor may have advanced while this scroll ran — chase it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func activate() {
|
||||
guard cursor >= 0, cursor < items.count else { return }
|
||||
activateTick &+= 1
|
||||
haptics.confirm()
|
||||
onActivate(items[cursor])
|
||||
}
|
||||
|
||||
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
|
||||
/// any other re-centers on it.
|
||||
private func tap(_ item: Item) {
|
||||
if let idx = index(of: item.id), idx == cursor {
|
||||
activate()
|
||||
} else if let idx = index(of: item.id) {
|
||||
cursor = idx
|
||||
lastNav = Date()
|
||||
haptics.move()
|
||||
selection = item.id
|
||||
commitScroll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection housekeeping
|
||||
|
||||
private func index(of id: Item.ID?) -> Int? {
|
||||
guard let id else { return nil }
|
||||
return items.firstIndex { $0.id == id }
|
||||
}
|
||||
|
||||
/// Keep `cursor`/`scrolledID`/`selection` consistent with `items`: seed on appear, and on a list
|
||||
/// change keep the same focused item when it survives, else clamp the cursor into range.
|
||||
private func reconcile() {
|
||||
guard !items.isEmpty else {
|
||||
cursor = 0
|
||||
if scrolledID != nil { scrolledID = nil }
|
||||
if selection != nil { selection = nil }
|
||||
return
|
||||
}
|
||||
if let sid = scrolledID, let idx = index(of: sid) {
|
||||
cursor = idx
|
||||
if selection != sid { selection = sid }
|
||||
} else {
|
||||
let idx = min(max(cursor, 0), items.count - 1)
|
||||
cursor = idx
|
||||
let id = items[idx].id
|
||||
scrolledID = id
|
||||
selection = id
|
||||
}
|
||||
}
|
||||
|
||||
private func boundaryBump(forward: Bool) {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
let recoil: CGFloat = forward ? -16 : 16
|
||||
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,232 @@
|
||||
// Chrome shared by the gamepad-driven screens (GamepadHomeView, GamepadSettingsView,
|
||||
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
|
||||
// controller-glyph hint bar, and the connected-controller status chip. One look across every
|
||||
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
|
||||
// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
import GameController
|
||||
|
||||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
||||
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
|
||||
@MainActor
|
||||
func buttonGlyph(
|
||||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||||
) -> String {
|
||||
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
||||
?? fallback
|
||||
}
|
||||
|
||||
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
|
||||
/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar
|
||||
/// at all, so the iOS value hugs the top edge there.
|
||||
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
||||
#if os(macOS)
|
||||
26
|
||||
#else
|
||||
compact ? 4 : 10
|
||||
#endif
|
||||
}
|
||||
|
||||
/// One glyph + label cell in a hint bar.
|
||||
struct GamepadHint: Identifiable {
|
||||
let glyph: String
|
||||
let text: String
|
||||
var id: String { glyph + text }
|
||||
}
|
||||
|
||||
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
|
||||
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration.
|
||||
struct GamepadHintBar: View {
|
||||
let hints: [GamepadHint]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 18) {
|
||||
ForEach(hints) { hint in
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: hint.glyph)
|
||||
.font(.system(size: 19))
|
||||
.foregroundStyle(.white)
|
||||
Text(hint.text)
|
||||
}
|
||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||
}
|
||||
}
|
||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
}
|
||||
|
||||
/// The console backdrop: a living "aurora" field in the brand's violet family — soft color blobs
|
||||
/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player
|
||||
/// background but calmer (long 30–90 s periods, muted opacities, a legibility scrim on top, so it
|
||||
/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a
|
||||
/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode
|
||||
/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the
|
||||
/// two — radial gradients driven by a TimelineView give the same look with none of that risk.
|
||||
///
|
||||
/// Applied via `.background { }` — NOT as a ZStack sibling — so the `.ignoresSafeArea()` here
|
||||
/// can't inflate the caller's layout past the safe area (see the layout discipline note in
|
||||
/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
struct GamepadScreenBackground: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular
|
||||
/// speeds (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||||
private struct Blob {
|
||||
let color: Color
|
||||
let center: CGPoint
|
||||
let drift: CGSize
|
||||
let speed: (x: Double, y: Double)
|
||||
let phase: (x: Double, y: Double)
|
||||
/// Radius as a fraction of the view's larger dimension (+ breathing amplitude/speed).
|
||||
let radius: CGFloat
|
||||
let breathe: (amount: CGFloat, speed: Double)
|
||||
let opacity: Double
|
||||
}
|
||||
|
||||
/// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so the
|
||||
/// field shifts within one temperature instead of strobing through the rainbow.
|
||||
private static let blobs: [Blob] = [
|
||||
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
||||
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||||
speed: (0.111, 0.083), phase: (0.0, 1.9),
|
||||
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
|
||||
Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo
|
||||
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
|
||||
speed: (0.071, 0.096), phase: (2.4, 0.7),
|
||||
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
|
||||
Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum
|
||||
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
|
||||
speed: (0.089, 0.067), phase: (4.1, 3.2),
|
||||
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
|
||||
Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue
|
||||
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
|
||||
speed: (0.059, 0.104), phase: (1.2, 5.0),
|
||||
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if reduceMotion {
|
||||
field(at: 0)
|
||||
} else {
|
||||
// 30 Hz is plenty for centimeters-per-minute drift, and halves the redraw cost
|
||||
// of a battery-fed couch device vs. the default display rate.
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||
field(at: context.date.timeIntervalSinceReferenceDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private func field(at t: TimeInterval) -> some View {
|
||||
GeometryReader { geo in
|
||||
let side = max(geo.size.width, geo.size.height)
|
||||
ZStack {
|
||||
Color.black
|
||||
ZStack {
|
||||
ForEach(Self.blobs.indices, id: \.self) { i in
|
||||
blobView(Self.blobs[i], at: t, in: geo.size, side: side)
|
||||
}
|
||||
}
|
||||
// ±10° over ~5 min — the whole field very slowly warms and cools.
|
||||
.hueRotation(.degrees(sin(t * 0.021) * 10))
|
||||
// Composite the additive blobs offscreen once instead of per-layer.
|
||||
.drawingGroup()
|
||||
// Legibility scrim: the title (top) and detail/hints (bottom) always sit on
|
||||
// near-black, whatever the blobs are doing behind them.
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.55), location: 0),
|
||||
.init(color: .black.opacity(0.15), location: 0.35),
|
||||
.init(color: .black.opacity(0.20), location: 0.65),
|
||||
.init(color: .black.opacity(0.60), location: 1),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func blobView(_ blob: Blob, at t: TimeInterval, in size: CGSize, side: CGFloat) -> some View {
|
||||
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
|
||||
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||||
let r = side * blob.radius
|
||||
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
|
||||
return Circle()
|
||||
.fill(RadialGradient(
|
||||
colors: [blob.color, blob.color.opacity(0)],
|
||||
center: .center, startRadius: 0, endRadius: r / 2))
|
||||
.frame(width: r, height: r)
|
||||
.position(x: x * size.width, y: y * size.height)
|
||||
.opacity(blob.opacity)
|
||||
.blendMode(.plusLighter)
|
||||
}
|
||||
}
|
||||
|
||||
/// A darkening scrim behind a pinned tray (a screen title, the hints/detail bar, the keyboard
|
||||
/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row
|
||||
/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar.
|
||||
struct GamepadTrayScrim: View {
|
||||
let edge: VerticalEdge
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.92), location: 0),
|
||||
.init(color: .black.opacity(0.85), location: 0.55),
|
||||
.init(color: .black.opacity(0), location: 1),
|
||||
],
|
||||
startPoint: edge == .top ? .top : .bottom,
|
||||
endPoint: edge == .top ? .bottom : .top)
|
||||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
||||
// text always sits on the near-opaque part, rows dim before they reach it.
|
||||
.padding(edge == .top ? .bottom : .top, -32)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
||||
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||
/// when the pad or its battery state changes.
|
||||
struct ControllerStatusChip: View {
|
||||
let controller: GamepadManager.DiscoveredController
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: controller.hasTouchpadAndMotion
|
||||
? "playstation.logo" : "gamecontroller.fill")
|
||||
.font(.system(size: 12))
|
||||
Text(controller.name)
|
||||
.lineLimit(1)
|
||||
if let level = controller.batteryLevel {
|
||||
Image(systemName: batterySymbol(level))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
||||
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
|
||||
}
|
||||
}
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(.white.opacity(0.08)))
|
||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
}
|
||||
|
||||
private func batterySymbol(_ level: Float) -> String {
|
||||
if controller.isCharging { return "battery.100.bolt" }
|
||||
switch level {
|
||||
case ..<0.125: return "battery.0"
|
||||
case ..<0.375: return "battery.25"
|
||||
case ..<0.625: return "battery.50"
|
||||
case ..<0.875: return "battery.75"
|
||||
default: return "battery.100"
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,368 @@
|
||||
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host
|
||||
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built
|
||||
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
|
||||
// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
|
||||
// gamepad settings screen, and the carousel always ends in an Add Host tile that opens the
|
||||
// controller-keyboard add flow. (A tap still works as a fallback for all of it.)
|
||||
//
|
||||
// All the scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the launcher's
|
||||
// chrome. Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
|
||||
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
|
||||
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
|
||||
// status bar / home indicator. As a background it draws behind without affecting layout, so the
|
||||
// GeometryReader is sized to the safe area. The title and the controller-glyph hints are pinned with
|
||||
// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's
|
||||
// vertical budget — and the card is sized off the remaining height. macOS mounts it too (the
|
||||
// couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets
|
||||
// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
import GameController
|
||||
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
||||
/// action. Hashable so it can be the carousel's scroll-position identity.
|
||||
private enum GamepadHomeTarget: Hashable {
|
||||
case saved(UUID)
|
||||
case discovered(String)
|
||||
case addHost
|
||||
}
|
||||
|
||||
/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render
|
||||
/// from the live stores so nothing goes stale.
|
||||
private struct HomeTile: Identifiable {
|
||||
let id: GamepadHomeTarget
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var isOnline = false
|
||||
var isPaired = false
|
||||
var isConnecting = false
|
||||
/// Saved (solid monogram) vs. discovered-but-unsaved / action (tinted outline).
|
||||
var filled = false
|
||||
/// Only saved hosts have a library (matches the touch grid's context-menu gate).
|
||||
var hasLibrary = false
|
||||
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
||||
var icon: String?
|
||||
/// Whether the detail panel shows the online/paired pill (hosts yes, actions no).
|
||||
var showsStatus = true
|
||||
let activate: () -> Void
|
||||
}
|
||||
|
||||
struct GamepadHomeView: View {
|
||||
@ObservedObject var store: HostStore
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@Binding var libraryTarget: StoredHost?
|
||||
let connect: (StoredHost) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
|
||||
/// Same experimental gate the touch grid's "Browse Library…" context-menu item uses.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS; the window minimum keeps room
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@State private var selection: GamepadHomeTarget?
|
||||
@State private var showSettings = false
|
||||
@State private var showAddHost = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
hero(for: geo.size)
|
||||
}
|
||||
// Pinned inside the safe area, out of the carousel's vertical budget — never clipped.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
titleBar
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
.onAppear { discovery.start() }
|
||||
.onDisappear { discovery.stop() }
|
||||
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
||||
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView()
|
||||
.frame(width: 720, height: 640)
|
||||
}
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
.frame(width: 660, height: 620)
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#else
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
||||
|
||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||
let cardWidth = min(340, size.width * 0.84)
|
||||
// 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
|
||||
// the strip + detail always fit the region the safe-area insets leave.
|
||||
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
|
||||
VStack(spacing: compact ? 8 : 10) {
|
||||
Spacer(minLength: 0)
|
||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||
detailPanel
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Chrome
|
||||
|
||||
private var titleBar: some View {
|
||||
Text("Select a Host")
|
||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) {
|
||||
// Which pad is driving this UI (name + battery) — quiet, and only where there's
|
||||
// room; a compact-height phone gives the pixels to the carousel instead.
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.padding(.trailing, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Carousel
|
||||
|
||||
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
||||
GamepadCarousel(
|
||||
items: tiles,
|
||||
selection: $selection,
|
||||
itemWidth: cardWidth,
|
||||
spacing: 30,
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
// Stop consuming the controller while another screen is presented on top — otherwise
|
||||
// the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||
) { tile in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||
}
|
||||
.frame(height: cardHeight + 40)
|
||||
}
|
||||
|
||||
/// The host tile plus its focus treatment. Every continuous visual reads the scroll view's own
|
||||
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
|
||||
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
|
||||
/// brightness/saturation + a depth blur on the recessed neighbors.
|
||||
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
|
||||
GamepadHostTile(tile: tile, size: size)
|
||||
.scrollTransition { content, phase in
|
||||
let d = CGFloat(min(abs(phase.value), 1))
|
||||
let scale = 1 - d * 0.12
|
||||
let bright = Double(-d * 0.24)
|
||||
let sat = Double(1 - d * 0.42)
|
||||
let soft = d * 3
|
||||
let fade = Double(1 - d * 0.22)
|
||||
return content
|
||||
.scaleEffect(scale)
|
||||
.brightness(bright)
|
||||
.saturation(sat)
|
||||
.blur(radius: soft)
|
||||
.opacity(fade)
|
||||
}
|
||||
}
|
||||
|
||||
/// The "now focused" host, spelled out below the strip — empty (not hidden) so the layout
|
||||
/// doesn't jump as the selection changes.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let tile = tiles.first { $0.id == selection }
|
||||
VStack(spacing: 6) {
|
||||
Text(tile?.title ?? " ")
|
||||
.font(.geist(22, .bold, relativeTo: .title2))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 10) {
|
||||
Text(tile?.subtitle ?? " ")
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
if let tile, tile.showsStatus {
|
||||
statusPill(online: tile.isOnline, paired: tile.isPaired)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.animation(.smooth(duration: 0.25), value: selection)
|
||||
}
|
||||
|
||||
private func statusPill(online: Bool, paired: Bool) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(online ? Color.green : Color.white.opacity(0.35))
|
||||
.frame(width: 6, height: 6)
|
||||
Text(online ? "ONLINE" : "OFFLINE")
|
||||
if paired { Text("· PAIRED") }
|
||||
}
|
||||
.font(.geist(11, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
let selected = tiles.first { $0.id == selection }
|
||||
var hints = [GamepadHint(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||
text: selected?.id == .addHost ? "Add Host" : "Connect")]
|
||||
if libraryEnabled, selected?.hasLibrary == true {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||
}
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
|
||||
return hints
|
||||
}
|
||||
|
||||
// MARK: - Data + actions
|
||||
|
||||
/// Built fresh each render from the live stores (no stale value capture) — saved hosts first,
|
||||
/// then discovered-but-unsaved ones, then the Add Host action tile (so the strip is never
|
||||
/// empty and manual entry is always one press away).
|
||||
private var tiles: [HomeTile] {
|
||||
let saved = store.hosts.map { host in
|
||||
HomeTile(
|
||||
id: .saved(host.id),
|
||||
title: host.displayName,
|
||||
subtitle: "\(host.address):\(String(host.port))",
|
||||
isOnline: discovery.advertises(host),
|
||||
isPaired: host.pinnedSHA256 != nil,
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
filled: true,
|
||||
hasLibrary: true,
|
||||
activate: { connect(host) })
|
||||
}
|
||||
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
||||
HomeTile(
|
||||
id: .discovered(d.id),
|
||||
title: d.name,
|
||||
subtitle: "\(d.host):\(String(d.port))",
|
||||
isOnline: true,
|
||||
activate: { connectDiscovered(d) })
|
||||
}
|
||||
let add = HomeTile(
|
||||
id: .addHost,
|
||||
title: "Add Host",
|
||||
subtitle: "Register a host by address",
|
||||
icon: "plus",
|
||||
showsStatus: false,
|
||||
activate: { showAddHost = true })
|
||||
return saved + discovered + [add]
|
||||
}
|
||||
|
||||
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
||||
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
|
||||
private func openLibraryForSelected() {
|
||||
guard libraryEnabled, case .saved(let id) = selection,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
else { return }
|
||||
libraryTarget = host
|
||||
}
|
||||
}
|
||||
|
||||
/// One "console tile" in the host carousel — a dark-glass landscape card, bigger and bolder than the
|
||||
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
|
||||
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
|
||||
private struct GamepadHostTile: View {
|
||||
let tile: HomeTile
|
||||
let size: CGSize
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
monogramBadge
|
||||
Spacer(minLength: 0)
|
||||
if tile.isOnline {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 9, height: 9)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Text(tile.title)
|
||||
.font(.geist(23, .bold, relativeTo: .title2))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(tile.subtitle)
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(1)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
||||
startPoint: .top, endPoint: .bottom),
|
||||
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
||||
}
|
||||
|
||||
private var monogramBadge: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(tile.filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.68)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(Color.brand.opacity(0.16)))
|
||||
if tile.isConnecting {
|
||||
ProgressView().tint(.white)
|
||||
} else if let icon = tile.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(Color.brand)
|
||||
} else {
|
||||
Text(monogram(tile.title))
|
||||
.font(.geistFixed(25, .bold))
|
||||
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
.overlay {
|
||||
if !tile.filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func monogram(_ name: String) -> String {
|
||||
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||
return String(first).uppercased()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,182 @@
|
||||
// A controller-driven on-screen keyboard for the gamepad UI's text fields (iOS/iPadOS only) —
|
||||
// iOS has no system keyboard a game controller can drive (the tvOS fullscreen entry doesn't
|
||||
// exist here), so without this, adding a host from the couch would end with "now touch the
|
||||
// screen". Dpad/stick moves a key cursor over a fixed grid, A types, X backspaces, B/Y confirms.
|
||||
// Lowercase + digits + the hostname/address punctuation is deliberately the whole character set:
|
||||
// these fields hold names, addresses and ports, not prose.
|
||||
//
|
||||
// Edits are applied to the binding live (the caller's field row shows every keystroke), so
|
||||
// closing the keyboard is always "done" — there is no separate cancel/commit step to get wrong.
|
||||
// Touch stays a fallback: every keycap is tappable.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct GamepadKeyboard: View {
|
||||
@Binding var text: String
|
||||
/// Restricts typed characters (e.g. digits for a port field); backspace always works.
|
||||
var allowed: CharacterSet?
|
||||
/// B / Y / the Done key — the binding already holds the final text.
|
||||
let onDone: () -> Void
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
@State private var cursor = GridPos(row: 1, col: 0) // opens on "q"
|
||||
@State private var pressTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
#if os(iOS)
|
||||
/// `.compact` (landscape phone): shorter keycaps so the tray leaves room for the field rows.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
|
||||
private struct GridPos: Hashable {
|
||||
var row: Int
|
||||
var col: Int
|
||||
}
|
||||
|
||||
private enum Key: Hashable {
|
||||
case char(Character)
|
||||
case space
|
||||
case backspace
|
||||
case done
|
||||
}
|
||||
|
||||
/// Digits first (addresses/ports), then letters; the last char column carries the
|
||||
/// hostname/address punctuation.
|
||||
private static let rows: [[Key]] = [
|
||||
Array("1234567890").map(Key.char),
|
||||
Array("qwertyuiop").map(Key.char),
|
||||
Array("asdfghjkl-").map(Key.char),
|
||||
Array("zxcvbnm._:").map(Key.char),
|
||||
[.space, .backspace, .done],
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: compact ? 5 : 7) {
|
||||
ForEach(Self.rows.indices, id: \.self) { r in
|
||||
HStack(spacing: compact ? 5 : 7) {
|
||||
ForEach(Self.rows[r].indices, id: \.self) { c in
|
||||
keycap(Self.rows[r][c], focused: cursor == GridPos(row: r, col: c))
|
||||
.onTapGesture {
|
||||
cursor = GridPos(row: r, col: c)
|
||||
press(Self.rows[r][c])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 560)
|
||||
.padding(compact ? 10 : 14)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
.onAppear {
|
||||
wire()
|
||||
input.start()
|
||||
}
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keycaps
|
||||
|
||||
@ViewBuilder private func keycap(_ key: Key, focused: Bool) -> some View {
|
||||
Group {
|
||||
switch key {
|
||||
case .char(let c):
|
||||
Text(String(c)).font(.geistFixed(18, .medium))
|
||||
case .space:
|
||||
Image(systemName: "space")
|
||||
case .backspace:
|
||||
Image(systemName: "delete.left")
|
||||
case .done:
|
||||
Label("Done", systemImage: "checkmark")
|
||||
.font(.geist(15, .semibold, relativeTo: .callout))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(focused ? Color.black : .white)
|
||||
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08)))
|
||||
}
|
||||
.animation(.smooth(duration: 0.12), value: focused)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
private func wire() {
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { press(Self.rows[cursor.row][cursor.col]) }
|
||||
input.onTertiary = { press(.backspace) }
|
||||
input.onSecondary = onDone
|
||||
input.onBack = onDone
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
var next = cursor
|
||||
switch direction {
|
||||
case .left: next.col -= 1
|
||||
case .right: next.col += 1
|
||||
case .up, .down:
|
||||
let row = cursor.row + (direction == .down ? 1 : -1)
|
||||
guard row >= 0, row < Self.rows.count else { return refuse() }
|
||||
// Map the column proportionally between rows of different widths, so e.g. Done
|
||||
// (rightmost of 3) goes up to the rightmost letters, not to "e".
|
||||
let from = max(1, Self.rows[cursor.row].count - 1)
|
||||
let to = Self.rows[row].count - 1
|
||||
next = GridPos(
|
||||
row: row,
|
||||
col: Int((Double(cursor.col) * Double(to) / Double(from)).rounded()))
|
||||
}
|
||||
guard next.row >= 0, next.row < Self.rows.count,
|
||||
next.col >= 0, next.col < Self.rows[next.row].count
|
||||
else { return refuse() }
|
||||
cursor = next
|
||||
haptics.move()
|
||||
}
|
||||
|
||||
private func press(_ key: Key) {
|
||||
switch key {
|
||||
case .char(let c):
|
||||
if let allowed, !c.unicodeScalars.allSatisfy(allowed.contains) { return refuse() }
|
||||
text.append(c)
|
||||
case .space:
|
||||
if let allowed, !allowed.contains(" ") { return refuse() }
|
||||
text.append(" ")
|
||||
case .backspace:
|
||||
guard !text.isEmpty else { return refuse() }
|
||||
text.removeLast()
|
||||
case .done:
|
||||
haptics.confirm()
|
||||
onDone()
|
||||
return
|
||||
}
|
||||
pressTick &+= 1
|
||||
haptics.move()
|
||||
}
|
||||
|
||||
/// Refused input (edge of the grid, a disallowed character, deleting nothing).
|
||||
private func refuse() {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,178 @@
|
||||
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for
|
||||
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a
|
||||
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs
|
||||
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus
|
||||
// cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
||||
//
|
||||
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
|
||||
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
|
||||
// first-class fallback — tapping a row focuses AND activates it (rows are always fully visible, so
|
||||
// the carousel's "first tap re-centers" step would only add friction here), and free finger
|
||||
// scrolling is never hijacked back to the focused row until the next controller move.
|
||||
//
|
||||
// Feedback is dual-channel like the carousel: `.sensoryFeedback` ticks the DEVICE Taptic engine,
|
||||
// `MenuHaptics` ticks the CONTROLLER. Moves and value changes get the crisp detent; a refused
|
||||
// move at either end gets the dull boundary thud plus a short vertical recoil.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
|
||||
let items: [Item]
|
||||
/// Output only: the list WRITES the focused item's id here (e.g. for a caller's hint bar).
|
||||
@Binding var focusID: Item.ID?
|
||||
/// Left/right on the focused row. Return whether the value actually changed — true plays the
|
||||
/// move detent, false the boundary thud (end of a clamped range, or nothing to adjust).
|
||||
var onAdjust: ((Item, Int) -> Bool)?
|
||||
/// A → activate the focused row (toggle it, open it, run it — the caller decides).
|
||||
let onActivate: (Item) -> Void
|
||||
/// B → back/dismiss; nil disables it.
|
||||
var onBack: (() -> Void)?
|
||||
/// Whether this list currently owns controller input — same handoff contract as
|
||||
/// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad).
|
||||
var isActive: Bool = true
|
||||
@ViewBuilder let row: (Item, _ focused: Bool) -> Row
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
/// Authoritative focus cursor (index into `items`).
|
||||
@State private var cursor = 0
|
||||
/// A short vertical recoil when a move is refused at a list end.
|
||||
@State private var bumpOffset: CGFloat = 0
|
||||
/// `.sensoryFeedback` counters (see GamepadCarousel): device ticks for activate / value-change
|
||||
/// / end-stop events; moves trigger on `cursor` itself.
|
||||
@State private var activateTick = 0
|
||||
@State private var adjustTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(spacing: 6) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||
row(item, idx == cursor && isActive)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(idx) }
|
||||
.id(item.id)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden.
|
||||
.scrollIndicators(.never)
|
||||
.offset(y: bumpOffset)
|
||||
.onChange(of: cursor) { _, newValue in
|
||||
guard newValue >= 0, newValue < items.count else { return }
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
proxy.scrollTo(items[newValue].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.selection, trigger: adjustTick)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
.onAppear {
|
||||
reconcile()
|
||||
wire()
|
||||
if isActive { input.start() }
|
||||
}
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
.onChange(of: isActive) { _, active in
|
||||
if active {
|
||||
wire()
|
||||
input.start()
|
||||
} else {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
}
|
||||
// Re-seed a dropped focus AND re-wire the input callbacks so they capture the current
|
||||
// `items` value (a plain array — it would otherwise go stale in the stored closures).
|
||||
.onChange(of: items.map(\.id)) { _, _ in
|
||||
reconcile()
|
||||
wire()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
input.onMove = { direction in
|
||||
switch direction {
|
||||
case .up: step(by: -1)
|
||||
case .down: step(by: 1)
|
||||
case .left: adjust(by: -1)
|
||||
case .right: adjust(by: 1)
|
||||
}
|
||||
}
|
||||
input.onConfirm = { activate() }
|
||||
input.onBack = onBack
|
||||
}
|
||||
|
||||
private func step(by delta: Int) {
|
||||
guard !items.isEmpty else { return }
|
||||
let target = cursor + delta
|
||||
guard target >= 0, target < items.count else { return boundaryBump(forward: delta > 0) }
|
||||
cursor = target
|
||||
focusID = items[target].id
|
||||
haptics.move()
|
||||
}
|
||||
|
||||
private func adjust(by delta: Int) {
|
||||
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
|
||||
if onAdjust(items[cursor], delta) {
|
||||
adjustTick &+= 1
|
||||
haptics.move()
|
||||
} else {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
}
|
||||
}
|
||||
|
||||
private func activate() {
|
||||
guard cursor >= 0, cursor < items.count else { return }
|
||||
activateTick &+= 1
|
||||
haptics.confirm()
|
||||
onActivate(items[cursor])
|
||||
}
|
||||
|
||||
/// Touch fallback: a tap focuses the row and activates it in one go.
|
||||
private func tap(_ idx: Int) {
|
||||
guard idx >= 0, idx < items.count else { return }
|
||||
if cursor != idx {
|
||||
cursor = idx
|
||||
focusID = items[idx].id
|
||||
}
|
||||
activate()
|
||||
}
|
||||
|
||||
/// Keep `cursor`/`focusID` consistent with `items`: seed on appear; on a list change keep the
|
||||
/// same focused item when it survives, else clamp the cursor into range.
|
||||
private func reconcile() {
|
||||
guard !items.isEmpty else {
|
||||
cursor = 0
|
||||
if focusID != nil { focusID = nil }
|
||||
return
|
||||
}
|
||||
if let id = focusID, let idx = items.firstIndex(where: { $0.id == id }) {
|
||||
cursor = idx
|
||||
} else {
|
||||
cursor = min(max(cursor, 0), items.count - 1)
|
||||
focusID = items[cursor].id
|
||||
}
|
||||
}
|
||||
|
||||
private func boundaryBump(forward: Bool) {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
let recoil: CGFloat = forward ? -14 : 14
|
||||
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,250 @@
|
||||
// The home screen: a grid of saved hosts + an "On this network" section of mDNS-discovered
|
||||
// hosts, with the add/settings toolbar and the pairing / speed-test / add / settings
|
||||
// navigation. The connect logic lives in ContentView (it reads the @AppStorage stream mode) and
|
||||
// is passed in as closures.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(tvOS)
|
||||
import SwiftUINavigationTransitions
|
||||
#endif
|
||||
|
||||
struct HomeView: View {
|
||||
@ObservedObject var store: HostStore
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@Binding var showAddHost: Bool
|
||||
@Binding var pairingTarget: StoredHost?
|
||||
@Binding var speedTestTarget: StoredHost?
|
||||
@Binding var libraryTarget: StoredHost?
|
||||
#if !os(macOS)
|
||||
@Binding var showSettings: Bool
|
||||
#endif
|
||||
let connect: (StoredHost) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
||||
let onPaired: (StoredHost, Data) -> Void
|
||||
/// Picked a title in the (experimental) library — start a session that launches it.
|
||||
let onLaunchTitle: (StoredHost, String) -> Void
|
||||
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
if !store.hosts.isEmpty {
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(store.hosts) { host in
|
||||
hostCard(host)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
if !discoveredUnsaved.isEmpty {
|
||||
discoveredSection
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Actions live below the hosts, not between them.
|
||||
HStack(spacing: 32) {
|
||||
Button {
|
||||
showAddHost = true
|
||||
} label: {
|
||||
Label("Add Host", systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
.padding(.top, 24)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunk")
|
||||
// Browse the LAN for advertised hosts only while the grid is up — not during a
|
||||
// session. The home appears/disappears as the stream swaps in and out.
|
||||
.onAppear { discovery.start() }
|
||||
.onDisappear { discovery.stop() }
|
||||
#if os(tvOS)
|
||||
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
||||
// pops) instead of modal overlays.
|
||||
.navigationDestination(isPresented: $showAddHost) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
.navigationDestination(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.navigationDestination(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in onPaired(host, fingerprint) }
|
||||
}
|
||||
.navigationDestination(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
}
|
||||
.navigationDestination(item: $libraryTarget) { host in
|
||||
LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) })
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
// Adjacent trailing items share one glass pill (the system default).
|
||||
ToolbarItem(placement: .topBarTrailing) { settingsButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
#else
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
addHostButton
|
||||
.help("Add a host")
|
||||
}
|
||||
ToolbarItem {
|
||||
SettingsLink {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.help("Stream mode and settings")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 360)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// The Settings-app slide for every push in this stack (top-level routes AND
|
||||
// the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade.
|
||||
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast
|
||||
// with just a hint of life, no visible overshoot ping-pong.
|
||||
.customNavigationTransition(
|
||||
.slide.animation(.interpolatingSpring(stiffness: 300, damping: 30)))
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
#if os(iOS)
|
||||
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
||||
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
||||
// a stack (double title bars). `settingsSheetSizing()` widens the sheet on iPad for the
|
||||
// two-column layout.
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
.settingsSheetSizing()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
private func hostCard(_ host: StoredHost) -> some View {
|
||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
||||
return HostCardView(
|
||||
host: host,
|
||||
isOnline: discovery.advertises(host),
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
isMostRecent: host.id == mostRecentHostID,
|
||||
isBusy: model.isBusy,
|
||||
onConnect: { connect(host) },
|
||||
onPair: { if !model.isBusy { pairingTarget = host } },
|
||||
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
||||
onForget: { store.forgetIdentity(host) },
|
||||
onRemove: { store.remove(host) },
|
||||
onBrowseLibrary: onBrowseLibrary)
|
||||
}
|
||||
|
||||
private var discoveredSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.geist(15, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(discoveredUnsaved) { discovered in
|
||||
DiscoveredCardView(
|
||||
discovered: discovered, isBusy: model.isBusy,
|
||||
onConnect: { connectDiscovered(discovered) })
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.horizontal, .bottom])
|
||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||
}
|
||||
|
||||
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad
|
||||
/// launcher so both screens classify hosts identically).
|
||||
private var discoveredUnsaved: [DiscoveredHost] {
|
||||
discovery.unsaved(among: store.hosts)
|
||||
}
|
||||
|
||||
/// The host of the most recent session — its card carries the accent ring.
|
||||
private var mostRecentHostID: UUID? {
|
||||
store.hosts
|
||||
.compactMap { host in host.lastConnected.map { (host.id, $0) } }
|
||||
.max { $0.1 < $1.1 }?.0
|
||||
}
|
||||
|
||||
// MARK: - Chrome
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
|
||||
} description: {
|
||||
Text("Add your punktfunk host with the + button.")
|
||||
} actions: {
|
||||
Button("Add Host") { showAddHost = true }
|
||||
.glassProminentButtonStyle()
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
Button("Settings") { showSettings = true }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var addHostButton: some View {
|
||||
Button {
|
||||
showAddHost = true
|
||||
} label: {
|
||||
Label("Add Host", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private var settingsButton: some View {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// macOS caps card width (a huge window shouldn't yield huge cards); on iOS the columns FILL
|
||||
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
|
||||
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
||||
private var gridColumns: [GridItem] {
|
||||
// Wider than before: the monogram card is a horizontal module (tile + address line), so
|
||||
// it needs room for a monospaced "IP:port" without truncating.
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
#else
|
||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||
#endif
|
||||
}
|
||||
|
||||
private var gridSpacing: CGFloat {
|
||||
#if os(tvOS)
|
||||
48 // the focused card scales up — give it room instead of overlapping siblings
|
||||
#else
|
||||
16
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
||||
// host (tap to save + connect). Both share the "monogram module" look — a squared brand-purple
|
||||
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
|
||||
// (address, status), framed by a hairline panel border. Industrial, not soft.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
|
||||
private struct CardMetrics {
|
||||
let tile: CGFloat // monogram tile side
|
||||
let monogram: CGFloat // monogram letter point size
|
||||
let name: CGFloat // host-name point size
|
||||
let meta: CGFloat // address (mono) point size
|
||||
let status: CGFloat // status-label (mono) point size
|
||||
let padding: CGFloat
|
||||
let spacing: CGFloat // tile ↔ text gap
|
||||
let radius: CGFloat
|
||||
|
||||
static var current: CardMetrics {
|
||||
#if os(iOS)
|
||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||
padding: 16, spacing: 14, radius: 12)
|
||||
#elseif os(tvOS)
|
||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
||||
padding: 18, spacing: 18, radius: 14)
|
||||
#else
|
||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||
padding: 13, spacing: 12, radius: 10)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet.
|
||||
private func monogram(_ name: String) -> String {
|
||||
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||
return String(first).uppercased()
|
||||
}
|
||||
|
||||
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
|
||||
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
||||
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.72)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(Color.brand.opacity(0.14)))
|
||||
if connecting {
|
||||
ProgressView().tint(filled ? .white : Color.brand)
|
||||
} else {
|
||||
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
|
||||
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
|
||||
// the clip below are belt-and-suspenders for an unusually wide glyph.
|
||||
Text(letter)
|
||||
.font(.geistFixed(m.monogram, .bold))
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(filled ? Color.white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: m.tile, height: m.tile)
|
||||
.clipShape(shape)
|
||||
.overlay {
|
||||
if !filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
|
||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||
struct HostCardView: View {
|
||||
let host: StoredHost
|
||||
/// Currently advertising on the LAN (matched against live mDNS discovery). False means
|
||||
/// "not seen on this network" — off, or a remote/cross-subnet host we can't observe.
|
||||
let isOnline: Bool
|
||||
let isConnecting: Bool
|
||||
let isMostRecent: Bool
|
||||
let isBusy: Bool
|
||||
let onConnect: () -> Void
|
||||
let onPair: () -> Void
|
||||
let onSpeedTest: () -> Void
|
||||
let onForget: () -> Void
|
||||
let onRemove: () -> Void
|
||||
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
||||
var onBrowseLibrary: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.displayName)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
statusRow(m)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
||||
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
||||
// and a brand accent bar down the leading edge for the most-recent host.
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .leading) {
|
||||
if isMostRecent {
|
||||
Rectangle().fill(Color.brand).frame(width: 3)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
.contextMenu {
|
||||
Button("Pair with PIN…", action: onPair)
|
||||
Button("Test Network Speed…", action: onSpeedTest)
|
||||
if let onBrowseLibrary {
|
||||
Button("Browse Library…", action: onBrowseLibrary)
|
||||
}
|
||||
if host.pinnedSHA256 != nil {
|
||||
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
||||
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
||||
Button("Forget Identity (re-pair to reconnect)", action: onForget)
|
||||
}
|
||||
Button("Remove", role: .destructive, action: onRemove)
|
||||
}
|
||||
}
|
||||
|
||||
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
|
||||
/// certificate is pinned (the lock state, spelled out).
|
||||
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 6, height: 6)
|
||||
// The state is spelled out in the adjacent text, so the pip is decorative —
|
||||
// otherwise VoiceOver reads the status twice ("Online, ONLINE …").
|
||||
.accessibilityHidden(true)
|
||||
Text(isOnline ? "ONLINE" : "OFFLINE")
|
||||
if host.pinnedSHA256 != nil {
|
||||
Text("· PAIRED")
|
||||
}
|
||||
}
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||
struct DiscoveredCardView: View {
|
||||
let discovered: DiscoveredHost
|
||||
let isBusy: Bool
|
||||
let onConnect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(discovered.name)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: discovered.requiresPairing
|
||||
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: m.status))
|
||||
.accessibilityHidden(true) // decorative; the adjacent text says the state
|
||||
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
|
||||
}
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.secondary.opacity(0.3),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The iOS host-card press/hover treatment, one style for both idioms:
|
||||
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
|
||||
/// inert without a pointer.)
|
||||
/// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the
|
||||
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
|
||||
/// press scale doubles as click feedback.)
|
||||
struct HostCardButtonStyle: ButtonStyle {
|
||||
var cornerRadius: CGFloat
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
|
||||
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
|
||||
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.hoverEffect(.highlight)
|
||||
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
|
||||
// hardware on iPad → silently ignored there.
|
||||
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
|
||||
pressed ? .impact(weight: .light) : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,148 @@
|
||||
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS — see LibraryView's
|
||||
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
|
||||
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
|
||||
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
||||
// the controller-glyph hints. A steps through covers, A launches the centered title, B closes, and
|
||||
// the shoulders (L1/R1) jump a handful at a time through a long library.
|
||||
//
|
||||
// Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
|
||||
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
|
||||
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
|
||||
// status bar / home indicator. As a background it draws behind without affecting layout, so the
|
||||
// GeometryReader is sized to the safe area, and the controller-glyph hints are pinned inside it with
|
||||
// `.safeAreaInset(.bottom, alignment: .leading)`. Cover size is then derived from the height that
|
||||
// remains, so a tall 2:3 poster + the detail line always fit.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
import GameController
|
||||
|
||||
struct LibraryCoverflowView: View {
|
||||
let games: [GameEntry]
|
||||
let imageSession: URLSession?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
||||
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — drives a tighter poster so everything still fits.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS
|
||||
#endif
|
||||
@State private var selection: String?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
content(for: geo.size)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
}
|
||||
|
||||
@ViewBuilder private func content(for size: CGSize) -> some View {
|
||||
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
|
||||
// safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and
|
||||
// clamped by width on a narrow screen.
|
||||
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
|
||||
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
|
||||
let coverWidth = coverHeight * 2 / 3
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 4)
|
||||
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
|
||||
detailPanel
|
||||
.padding(.top, 12)
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func carousel(coverWidth: CGFloat, coverHeight: CGFloat) -> some View {
|
||||
GamepadCarousel(
|
||||
items: games,
|
||||
selection: $selection,
|
||||
itemWidth: coverWidth,
|
||||
spacing: 34,
|
||||
onActivate: { onLaunch?($0.id) },
|
||||
onBack: { onDismiss?() },
|
||||
shoulderJump: 5
|
||||
) { game in
|
||||
cover(game, width: coverWidth, height: coverHeight)
|
||||
}
|
||||
.frame(height: coverHeight + 44)
|
||||
}
|
||||
|
||||
/// One cover + the coverflow recede. Every continuous visual reads the scroll view's own
|
||||
/// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen
|
||||
/// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the
|
||||
/// scale/rotation/opacity ramp already makes the centered cover prominent.
|
||||
private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
|
||||
.scrollTransition { content, phase in
|
||||
let v = phase.value
|
||||
let d = CGFloat(min(abs(v), 1))
|
||||
let scale = 1 - d * 0.24
|
||||
let rot = v * -38
|
||||
let anchor: UnitPoint = v < 0 ? .trailing : .leading
|
||||
let bright = Double(-d * 0.22)
|
||||
let fade = Double(1 - d * 0.38)
|
||||
return content
|
||||
.scaleEffect(scale)
|
||||
.rotation3DEffect(
|
||||
.degrees(rot), axis: (x: 0, y: 1, z: 0), anchor: anchor, perspective: 0.55)
|
||||
.brightness(bright)
|
||||
.opacity(fade)
|
||||
}
|
||||
}
|
||||
|
||||
/// The centered title + store tag — empty (not hidden) so the layout doesn't jump.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let game = games.first { $0.id == selection }
|
||||
VStack(spacing: 6) {
|
||||
Text(game?.title ?? " ")
|
||||
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.multilineTextAlignment(.center)
|
||||
if let game {
|
||||
Text(game.isCustom ? "CUSTOM" : "STEAM")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.animation(.smooth(duration: 0.25), value: selection)
|
||||
}
|
||||
|
||||
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if onLaunch != nil {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
|
||||
}
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
|
||||
return hints
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,199 @@
|
||||
// Experimental game-library browser (plan step 3, gated behind DefaultsKey.libraryEnabled).
|
||||
// Renders a poster grid of the host's library fetched over the management API. Read-only:
|
||||
// launching a chosen title is a later step. Reached from a host card's "Browse Library…"
|
||||
// context-menu action, which only appears when the feature flag is on.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct LibraryView: View {
|
||||
@ObservedObject var store: HostStore
|
||||
let host: StoredHost
|
||||
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
|
||||
/// through). `nil` ⇒ browse-only (cards aren't tappable).
|
||||
var onLaunch: ((String) -> Void)? = nil
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var games: [GameEntry] = []
|
||||
@State private var loading = false
|
||||
@State private var errorText: String?
|
||||
/// Authenticated session for cover-art fetches (the same paired identity + host pinning as the
|
||||
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
||||
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
||||
@State private var imageSession: URLSession?
|
||||
#if os(iOS) || os(macOS)
|
||||
// Gamepad-driven browsing (iOS/iPadOS/macOS) — see ContentView's identical gate. tvOS keeps
|
||||
// its existing plain-grid presentation of this same view unchanged.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle("\(host.displayName) — Library")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(macOS)
|
||||
ToolbarItemGroup { reloadButton }
|
||||
#else
|
||||
ToolbarItem(placement: .primaryAction) { reloadButton }
|
||||
#endif
|
||||
// A gamepad-only user can't swipe-to-dismiss the sheet this view is presented in
|
||||
// (ContentView's `.sheet(item: $libraryTarget)`) — give it a focusable, dpad-reachable
|
||||
// Close action. tvOS already has its own pushed-navigation back (Menu button).
|
||||
#if !os(tvOS)
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.task { await load() }
|
||||
.onDisappear {
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if loading && games.isEmpty {
|
||||
ProgressView("Loading library…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorText, games.isEmpty {
|
||||
errorState(errorText)
|
||||
} else if games.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
#if os(iOS) || os(macOS)
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||
onDismiss: { dismiss() })
|
||||
} else {
|
||||
grid
|
||||
}
|
||||
#else
|
||||
grid
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(games) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
#if os(tvOS)
|
||||
let minW: CGFloat = 220
|
||||
#else
|
||||
let minW: CGFloat = 130
|
||||
#endif
|
||||
return [GridItem(.adaptive(minimum: minW), spacing: 18)]
|
||||
}
|
||||
|
||||
private func errorState(_ text: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(text)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 420)
|
||||
Button("Retry") { Task { await load() } }
|
||||
.glassProminentButtonStyle()
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "square.grid.2x2")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No games found on this host.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var reloadButton: some View {
|
||||
Button { Task { await load() } } label: {
|
||||
Label("Reload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(loading)
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
loading = true
|
||||
errorText = nil
|
||||
let current = store.hosts.first { $0.id == host.id } ?? host
|
||||
// mTLS uses this client's persistent identity (the host paired it over QUIC). No identity
|
||||
// yet → the user hasn't connected/paired, which is also when there's nothing to browse.
|
||||
guard let identity = (try? ClientIdentityStore.shared.load())?.identity else {
|
||||
games = []
|
||||
errorText = "Connect to this host once first — the library uses the identity created "
|
||||
+ "on pairing to authenticate."
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
do {
|
||||
games = try await LibraryClient.fetch(
|
||||
address: current.address,
|
||||
port: current.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM,
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256)
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = try LibraryImageLoader.session(
|
||||
address: current.address,
|
||||
port: current.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM,
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256)
|
||||
} catch {
|
||||
games = []
|
||||
errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs
|
||||
/// (portrait → header → hero) and finally a text placeholder.
|
||||
private struct GameCard: View {
|
||||
let game: GameEntry
|
||||
let imageSession: URLSession?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
Text(game.title)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Reusable library widgets, shared by the touch grid (LibraryView's `GameCard`) and the gamepad
|
||||
// coverflow (LibraryCoverflowView's cover cell).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster —
|
||||
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
|
||||
struct StoreBadge: View {
|
||||
let isCustom: Bool
|
||||
|
||||
var body: some View {
|
||||
Text(isCustom ? "Custom" : "Steam")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
private typealias PlatformImage = UIImage
|
||||
#elseif canImport(AppKit)
|
||||
private typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
private extension Image {
|
||||
init(platformImage: PlatformImage) {
|
||||
#if canImport(UIKit)
|
||||
self.init(uiImage: platformImage)
|
||||
#elseif canImport(AppKit)
|
||||
self.init(nsImage: platformImage)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own
|
||||
/// art proxy, not just public CDNs — see `LibraryImageLoader`), advancing past any that fail to
|
||||
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
|
||||
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
|
||||
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
|
||||
/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` —
|
||||
/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art.
|
||||
struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let session: URLSession?
|
||||
@State private var index = 0
|
||||
@State private var image: PlatformImage?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
Image(platformImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else if index < candidates.count {
|
||||
ZStack { placeholder; ProgressView() }
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.task(id: index) { await loadCurrent() }
|
||||
}
|
||||
|
||||
private func loadCurrent() async {
|
||||
guard index < candidates.count else { return }
|
||||
guard let session, let data = try? await session.data(from: candidates[index]).0,
|
||||
let loaded = PlatformImage(data: data)
|
||||
else {
|
||||
index += 1 // advance to the next candidate (or past the end → placeholder)
|
||||
return
|
||||
}
|
||||
image = loaded
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Network speed-test sheet (roadmap §9): connect to the host, ask it to burst probe
|
||||
// filler over the real data plane (FEC-encoded UDP, video paused — the measurement IS the
|
||||
// streaming path), poll the measurement, and recommend a bitrate (~70% of the measured
|
||||
// goodput, headroom for encoder burstiness). "Use N Mbps" writes the bitrate setting; it
|
||||
// applies from the next session.
|
||||
//
|
||||
// Runs only while idle (the host serves one session at a time, so it can't share the wire
|
||||
// with a live stream — the host-card grid is the idle UI anyway). Trust: a pinned host is
|
||||
// verified as usual; an unpinned one is probed trust-on-first-use WITHOUT persisting
|
||||
// anything — a bandwidth number doesn't justify a trust decision.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Dismissal must abandon the in-flight probe: the connect/poll loop runs detached and
|
||||
/// checks this flag, closing the connection itself. Only the flag is shared; it is safe
|
||||
/// to read/write from the loop and the main actor (single Bool, torn reads harmless).
|
||||
private final class ProbeToken: @unchecked Sendable {
|
||||
var cancelled = false
|
||||
}
|
||||
|
||||
/// What the host is asked to burst: the host's full probe ceiling (it clamps to ≤ 3 Gbps),
|
||||
/// so the measurement surfaces the link's real ceiling instead of an artificial cap —
|
||||
/// bursting ABOVE what the link can carry is how the probe finds where delivery falls off.
|
||||
/// Five seconds (was 2 s) averages out the scheduler/recv jitter that made a short probe swing
|
||||
/// wildly (50 vs 900 Mbps on the same link) — long enough for the host's steady-state send and
|
||||
/// the client's recv drain to settle. File-scope so the detached probe task reads them without
|
||||
/// crossing into the view's main actor.
|
||||
private let probeTargetKbps: UInt32 = 3_000_000
|
||||
private let probeDurationMs: UInt32 = 5_000
|
||||
|
||||
struct SpeedTestSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let host: StoredHost
|
||||
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
|
||||
private enum Phase: Equatable {
|
||||
case connecting
|
||||
case probing(partial: PunktfunkConnection.ProbeResult?)
|
||||
case done(PunktfunkConnection.ProbeResult)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@State private var phase: Phase = .connecting
|
||||
@State private var token = ProbeToken()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
switch phase {
|
||||
case .connecting:
|
||||
ProgressView("Connecting…")
|
||||
.padding(.vertical, 12)
|
||||
case .probing(let partial):
|
||||
VStack(spacing: 8) {
|
||||
ProgressView("Measuring — the host is bursting probe data…")
|
||||
if let partial, partial.throughputKbps > 0 {
|
||||
Text("~\(Self.mbpsLabel(kbps: Int(partial.throughputKbps))) so far")
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
case .done(let result):
|
||||
resultView(result)
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
HStack(spacing: 24) {
|
||||
Button(phaseIsFinal ? "Close" : "Cancel", role: .cancel) {
|
||||
token.cancelled = true
|
||||
dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
if case .done(let result) = phase, let rec = Self.recommendedKbps(result) {
|
||||
Button("Use \(Self.mbpsLabel(kbps: rec))") {
|
||||
bitrateKbps = rec
|
||||
dismiss()
|
||||
}
|
||||
.glassProminentButtonStyle()
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
if case .failed = phase {
|
||||
Button("Retry") { run() }
|
||||
.glassProminentButtonStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(60)
|
||||
#else
|
||||
.padding(24)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.frame(width: 420)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// Bottom sheet rather than a full-screen modal; .medium stays put as the result view
|
||||
// swaps in (a measured height would resize the sheet mid-probe).
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
.onAppear { run() }
|
||||
.onDisappear { token.cancelled = true }
|
||||
}
|
||||
|
||||
private var phaseIsFinal: Bool {
|
||||
switch phase {
|
||||
case .done, .failed: return true
|
||||
case .connecting, .probing: return false
|
||||
}
|
||||
}
|
||||
|
||||
private func resultView(_ result: PunktfunkConnection.ProbeResult) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(Self.mbpsLabel(kbps: Int(result.throughputKbps)))
|
||||
.font(.system(.largeTitle, design: .rounded).weight(.semibold))
|
||||
.monospacedDigit()
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {
|
||||
GridRow {
|
||||
Text("Loss").foregroundStyle(.secondary)
|
||||
Text(String(format: "%.1f %%", result.lossPct)).monospacedDigit()
|
||||
}
|
||||
GridRow {
|
||||
Text("Received").foregroundStyle(.secondary)
|
||||
Text("\(ByteCountFormatter.string(fromByteCount: Int64(result.recvBytes), countStyle: .binary)) in \(result.elapsedMs) ms")
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
if let rec = Self.recommendedKbps(result) {
|
||||
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
|
||||
+ "(~70% of measured, headroom for encoder bursts).")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Text("Too little data made it through to recommend a bitrate — "
|
||||
+ "check the network and retry.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ~70% of the measured goodput, whole Mbps, clamped to the host's session bitrate
|
||||
/// ceiling (2 Gbps — it clamps any session request above that, so recommending more is
|
||||
/// pointless). nil when the measurement carried too little signal to recommend anything.
|
||||
static func recommendedKbps(_ result: PunktfunkConnection.ProbeResult) -> Int? {
|
||||
guard result.throughputKbps >= 2_000 else { return nil }
|
||||
let raw = Int(result.throughputKbps) * 7 / 10
|
||||
let wholeMbps = max(raw / 1_000, 2)
|
||||
return min(wholeMbps, 2_000) * 1_000
|
||||
}
|
||||
|
||||
static func mbpsLabel(kbps: Int) -> String {
|
||||
if kbps >= 1_000_000 {
|
||||
let gbps = Double(kbps) / 1_000_000
|
||||
return gbps == gbps.rounded()
|
||||
? "\(Int(gbps)) Gbps"
|
||||
: String(format: "%.1f Gbps", gbps)
|
||||
}
|
||||
return kbps % 1_000 == 0
|
||||
? "\(kbps / 1_000) Mbps"
|
||||
: String(format: "%.1f Mbps", Double(kbps) / 1_000)
|
||||
}
|
||||
|
||||
private func run() {
|
||||
phase = .connecting
|
||||
let token = token
|
||||
let address = host.address
|
||||
let port = host.port
|
||||
let pin = host.pinnedSHA256
|
||||
let (w, h, fps) = (UInt32(clamping: width), UInt32(clamping: height), UInt32(clamping: hz))
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// Connect (blocking) — same identity/trust as a session, but TOFU results are
|
||||
// NOT persisted from here.
|
||||
let identity = (try? ClientIdentityStore.shared.load())?.identity
|
||||
let conn: PunktfunkConnection
|
||||
do {
|
||||
conn = try PunktfunkConnection(
|
||||
host: address, port: port, width: w, height: h, refreshHz: fps,
|
||||
pinSHA256: pin, identity: identity)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
guard !token.cancelled else { return }
|
||||
phase = .failed(
|
||||
"Could not connect to \(address):\(port) — is punktfunk-host "
|
||||
+ "running and not mid-session?")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer { conn.close() }
|
||||
|
||||
conn.startSpeedTest(targetKbps: probeTargetKbps, durationMs: probeDurationMs)
|
||||
await MainActor.run { if !token.cancelled { phase = .probing(partial: nil) } }
|
||||
|
||||
// Poll until the host's end-of-burst report lands (or a generous deadline —
|
||||
// the host clamps the burst to ≤ 5 s).
|
||||
let deadline = Date().addingTimeInterval(Double(probeDurationMs) / 1000 + 8)
|
||||
var final: PunktfunkConnection.ProbeResult?
|
||||
while !token.cancelled, Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
guard let r = conn.probeResult() else { break } // closed underneath us
|
||||
if r.done {
|
||||
final = r
|
||||
break
|
||||
}
|
||||
await MainActor.run {
|
||||
if !token.cancelled { phase = .probing(partial: r) }
|
||||
}
|
||||
}
|
||||
let result = final
|
||||
await MainActor.run {
|
||||
guard !token.cancelled else { return }
|
||||
if let result {
|
||||
phase = .done(result)
|
||||
} else {
|
||||
phase = .failed(
|
||||
"The measurement never completed — the connection may have "
|
||||
+ "dropped mid-probe. Retry?")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user