diff --git a/clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/Contents.json b/clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/Contents.json new file mode 100644 index 00000000..5707e8eb --- /dev/null +++ b/clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "about-icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "about-icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/about-icon@1x.png b/clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/about-icon@1x.png new file mode 100644 index 00000000..9959cc99 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/about-icon@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/about-icon@2x.png b/clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/about-icon@2x.png new file mode 100644 index 00000000..f56da675 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/about-icon@2x.png differ diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index d758bf5b..efd81dba 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -83,14 +83,6 @@ struct ContentView: View { /// never covers the video. @State private var isFullscreen = false #endif - #if os(macOS) || os(tvOS) - /// Shows the start-of-stream shortcut banner (the Windows client's discoverability - /// pattern): raised on every transition to `.streaming`, dropped by the banner's own - /// 6-second task. Independent of the stats HUD so the keys are discoverable even with - /// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus - /// the remote-as-pointer controls, so it must be seen at least once per session. - @State private var showShortcutHint = false - #endif #if os(iOS) /// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)` /// — the disc must LEAVE the hierarchy so nothing composites over the metal layer). @@ -347,9 +339,6 @@ struct ContentView: View { .onChange(of: model.phase) { _, phase in switch phase { case .streaming: - #if os(macOS) || os(tvOS) - showShortcutHint = true // the 6 s shortcut banner, per session start - #endif #if os(iOS) showTouchExit = true // the off-tier exit disc's 8 s window, per session start #endif @@ -826,6 +815,7 @@ struct ContentView: View { onPaired: handlePaired, waker: waker, connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered, launchTitle: launchTitle, + wakeOnly: { wakeOnly($0) }, promptActive: consolePromptShowing) } else { HomeView( @@ -845,6 +835,7 @@ struct ContentView: View { onPaired: handlePaired, waker: waker, connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered, launchTitle: launchTitle, + wakeOnly: { wakeOnly($0) }, promptActive: consolePromptShowing) // On tvOS pairing/library normally present from HomeView's navigationDestinations // — which aren't mounted while the gamepad launcher is up. Give the launcher its @@ -1069,31 +1060,15 @@ struct ContentView: View { .transition(.opacity.combined(with: .scale(scale: 0.9))) } #endif - #if os(macOS) || os(tvOS) - // The start-of-stream shortcut banner (Windows-client parity): the + // The start-of-stream shortcut banner used to sit here (macOS/tvOS): the // platform's reserved controls on a glass pill for the first 6 seconds of - // every session — independent of the stats HUD, so the keys are - // discoverable even with statistics off. The banner's own task drops it - // (cancelled cleanly if the session view goes away first). On tvOS it - // carries the ONLY exits — Menu/B is swallowed during a session (the - // `.onExitCommand {}` in the tvOS session branch), so the hold gestures - // must be told to the user. - if captureEnabled && showShortcutHint { - Text(shortcutHintText) - .font(.geist(Self.shortcutHintFont, relativeTo: .caption)) - .foregroundStyle(.secondary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .glassBackground(Capsule()) - .transition(.opacity) - .task { - try? await Task.sleep(for: .seconds(6)) - withAnimation(.easeOut(duration: 0.6)) { - showShortcutHint = false - } - } - } - #endif + // every session. It is now a page you can OPEN — About ▸ Shortcuts, on + // both the touch and the controller surface (ShortcutsCatalog) — because + // a message that shows once, over the stream you have just connected to, + // is unavailable at the moment the question is actually asked. It also + // put a composited overlay above the stream for those 6 seconds, which on + // this path costs a refresh of display latency (see the iOS exit disc's + // note below); the reference page costs nothing during a session. } .padding(.bottom, 24) .animation(.easeOut(duration: 0.2), value: model.micMuted) @@ -1160,23 +1135,10 @@ struct ContentView: View { } #endif - #if os(macOS) - /// The reserved combos, told once per session. The mute segment appears only when the session - /// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie. - private var shortcutHintText: String { - let base = - "Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats" - return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base - } - private static let shortcutHintFont: CGFloat = 12 - #elseif os(tvOS) - private var shortcutHintText: String { - "Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect" - + " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks" - + " · Hold Play/Pause, or Select+X on a controller, for statistics" - } - private static let shortcutHintFont: CGFloat = 22 // read from the couch - #endif + // The two `shortcutHintText` strings that used to live here — one per platform, told once per + // session by the banner above — are now `ShortcutsCatalog.groups`, which both About pages + // render. The mic line is still conditional there for the same reason it was here: teaching a + // shortcut for a microphone that isn't on would be a lie. // MARK: - Connect diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index fb69d279..9e039b68 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -28,6 +28,18 @@ struct GamepadAddHostView: View { /// Whether this screen owns the controller — false while the shell is mid-transition or the /// connect takeover is up (see GamepadSettingsView's twin). var controllerActive = true + /// Non-nil ⇒ this screen is EDITING that saved host rather than registering a new one: the + /// fields start on its values and `onAdd` receives it back with only name/address/port + /// changed, so the fingerprint, pins, binding and MACs it carries survive the edit. A + /// re-typed address is the whole point of the screen (a host that moved), so nothing here + /// re-derives identity from it — that is the trust store's job, not this form's. + /// + /// Declared after the closures for the same trailing-closure reason as `close`, and it is a + /// plain value besides, so it can never capture one. + var editingHost: StoredHost? + /// One-shot seed guard: `@State` cannot be initialised from a property without a custom init, + /// and a custom init would break every existing trailing-closure call site. + @State private var seeded = false #if os(iOS) /// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits. @@ -60,12 +72,15 @@ struct GamepadAddHostView: View { .safeAreaInset(edge: .top, spacing: 0) { VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) { // Leading, like every gamepad heading — and no close chrome (B is the exit). - Text("Add Host") + Text(editingHost == nil ? "Add Host" : "Edit Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) if !compact { - Text("Hosts on this network appear automatically — add one by address " - + "for everything else.") + Text(editingHost == nil + ? "Hosts on this network appear automatically — add one by address " + + "for everything else." + : "Rename this host, or point it at a new address — its pairing and " + + "pinned cards are kept.") .font(.geist(metrics.detailFont, relativeTo: .caption)) .foregroundStyle(ink.fg(0.55)) .multilineTextAlignment(.leading) @@ -101,6 +116,17 @@ struct GamepadAddHostView: View { .onChange(of: port) { _, value in if value.count > 5 { port = String(value.prefix(5)) } } + // Seed the fields from the host being edited, exactly once: re-seeding on a later appear + // (the shell re-mounts a layer when the app returns from the background) would silently + // throw away whatever had been typed. + .onAppear { + guard !seeded else { return } + seeded = true + guard let host = editingHost else { return } + name = host.name + address = host.address + port = String(host.port) + } #if !os(tvOS) // The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware // keyboard's Esc and the macOS sheet's cancel working without chrome. @@ -205,7 +231,9 @@ struct GamepadAddHostView: View { 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), + Row( + id: "add", label: editingHost == nil ? "Add Host" : "Save Changes", + isAction: true), ] } @@ -264,10 +292,21 @@ struct GamepadAddHostView: View { openKeyboard("address") return } - onAdd(StoredHost( - name: name.trimmingCharacters(in: .whitespaces), - address: address.trimmingCharacters(in: .whitespaces), - port: UInt16(port) ?? 9777)) + let typedName = name.trimmingCharacters(in: .whitespaces) + let typedAddress = address.trimmingCharacters(in: .whitespaces) + let typedPort = UInt16(port) ?? 9777 + if var host = editingHost { + // Mutate a COPY of the stored record rather than building a fresh one: everything + // this form does not show — the pinned fingerprint, WoL MACs, pinned profile + // cards, the default binding, `addedAt` — has to survive a rename. + host.name = typedName + host.address = typedAddress + host.port = typedPort + onAdd(host) + } else { + onAdd(StoredHost( + name: typedName, address: typedAddress, port: typedPort)) + } performClose() default: openKeyboard(id) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 769d88e6..8959c6ec 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -48,6 +48,13 @@ struct GamepadCarousel: View where Item.ID: Hash var onTertiary: (() -> Void)? /// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to). var onBack: (() -> Void)? + /// UP → the focused item's own menu (the launcher's host options). Wiring it takes the whole + /// VERTICAL axis away from scrolling: up opens the menu and down goes inert, rather than up + /// meaning "menu" while down still stepped the strip. A horizontal carousel has no vertical + /// travel to spend, and the desktop and Android consoles both read the axis this way — one + /// meaning per direction is what makes the gesture learnable across the three of them. + /// nil leaves up/down as a second way to step (what every carousel without a menu still does). + var onUp: (() -> 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 @@ -301,6 +308,17 @@ struct GamepadCarousel: View where Item.ID: Hash // The poll carries only the buttons focus has no concept of: Y/X, the screen actions. input.onSecondary = onSecondary input.onTertiary = onTertiary + // UP is the one direction the poll may also read here, and ONLY to open the menu — it + // never calls `step`, so it cannot double-move against the focus engine. Routing it + // through `.onMoveCommand` instead was the obvious alternative and the wrong one: that + // stream is 4-way and its interception is input-source-dependent on real hardware (see + // GamepadMenuList's tvOS note), so claiming up there risks left/right focus with it. + // Nothing sits above the strip for the engine to move to, so this direction is free. + if let onUp { + input.onMove = { direction in + if direction == .up { onUp() } + } + } #else input.onMove = { move($0) } input.onConfirm = { activate() } @@ -312,6 +330,14 @@ struct GamepadCarousel: View where Item.ID: Hash } private func move(_ direction: GamepadMenuInput.Direction) { + // With a menu wired, vertical is the menu's axis, not a second scroll axis — see `onUp`. + if let onUp { + switch direction { + case .up: return onUp() + case .down: return + case .left, .right: break + } + } let forward = direction == .right || direction == .down step(by: forward ? 1 : -1, clampAtEnds: false) } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 0ef972db..406d8a63 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -94,6 +94,10 @@ struct GamepadHomeView: View { /// Launch a library title on a host — the in-place library layer's activate path (iOS; the /// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves). let launchTitle: (LibraryTarget, String) -> Void + /// Wake a host WITHOUT connecting (ContentView's `wakeOnly`) — the host menu's Wake row. The + /// tile's own A already wakes-and-connects; this is the other half, for bringing a machine up + /// to look at it rather than to stream from it right now. + let wakeOnly: (StoredHost) -> Void /// A console prompt (GamepadPromptView) is up over the home — it polls the same controller, so /// this screen must stand down for as long as it is. Same handoff contract as the connect /// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the @@ -122,6 +126,11 @@ struct GamepadHomeView: View { @State private var selection: GamepadHomeTarget? @State private var showSettings = false @State private var showAddHost = false + /// The card whose options menu is up (UP on a saved tile) — see GamepadHostOptionsView. + @State private var hostOptionsTarget: HostOptionsTarget? + /// The host being edited. Set from the options menu, which closes itself as it opens this so + /// the two are never stacked — depth stays ≤ 1, which is what `GamepadScreen` assumes. + @State private var editTarget: StoredHost? /// The console's input drop: true for the transition's 0.26 s, during which NO layer polls /// the controller — a double-tapped A can't push two screens, and the held button that /// caused the change is long released before the next poller starts (whose own @@ -204,19 +213,37 @@ struct GamepadHomeView: View { // shell's layers above ARE the presentation. #if os(macOS) .sheet(isPresented: $showSettings) { - GamepadSettingsView(store: store) + GamepadSettingsView(store: store, micAvailable: model.micAvailable) .frame(width: 720, height: 640) } .sheet(isPresented: $showAddHost) { GamepadAddHostView { store.add($0) } .frame(width: 660, height: 620) } + // Shorter than the forms above: a menu is five rows, and a sheet sized for a settings + // screen would be mostly empty field under them. + .sheet(item: $hostOptionsTarget) { target in + hostOptionsView(target, active: true) + .frame(width: 620, height: 460) + } + .sheet(item: $editTarget) { host in + editHostView(host, active: true) + .frame(width: 660, height: 620) + } .frame(minWidth: 640, minHeight: 420) #elseif os(tvOS) - .fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) } + .fullScreenCover(isPresented: $showSettings) { + GamepadSettingsView(store: store, micAvailable: model.micAvailable) + } .fullScreenCover(isPresented: $showAddHost) { GamepadAddHostView { store.add($0) } } + .fullScreenCover(item: $hostOptionsTarget) { target in + hostOptionsView(target, active: true) + } + .fullScreenCover(item: $editTarget) { host in + editHostView(host, active: true) + } #endif } @@ -264,6 +291,10 @@ struct GamepadHomeView: View { // can be raised from ON TOP of the library (launching a title on an unpaired host), where // it has to win. Backing out of it reveals whatever it interrupted. if let host = pairingTarget { return .pair(host) } + // Editing leads the menu that raised it: the menu clears itself on the way, so the two are + // never both set, and if they somehow were, the screen the user asked for last should win. + if let host = editTarget { return .editHost(host) } + if let target = hostOptionsTarget { return .hostOptions(target) } if showSettings { return .settings } if showAddHost { return .addHost } if let shelf = libraryTarget { return .library(shelf) } @@ -280,12 +311,17 @@ struct GamepadHomeView: View { GamepadSettingsView( store: store, close: { if !transitioning { showSettings = false } }, - controllerActive: active) + controllerActive: active, + micAvailable: model.micAvailable) case .addHost: GamepadAddHostView( onAdd: { store.add($0) }, close: { if !transitioning { showAddHost = false } }, controllerActive: active) + case .hostOptions(let target): + hostOptionsView(target, active: active) + case .editHost(let host): + editHostView(host, active: active) case .pair(let host): GamepadPairView( host: host, @@ -417,6 +453,7 @@ struct GamepadHomeView: View { onActivate: { $0.activate() }, onSecondary: { openLibraryForSelected() }, onTertiary: { showSettings = true }, + onUp: { openOptionsForSelected() }, isActive: homeOwnsController ) { tile, entrance in hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance) @@ -472,6 +509,14 @@ struct GamepadHomeView: View { glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library", action: { openLibraryForSelected() })) } + // Only a saved card has a menu, so the cell appears only where the press does something — + // the same honesty rule the Library cell above follows. A direction, not a button, so it + // is a plain arrow rather than a `buttonGlyph` (see the settings screen's "Adjust"). + if case .saved = selected?.id { + hints.append(.init( + glyph: "arrow.up", text: "Options", + action: { openOptionsForSelected() })) + } hints.append(.init( glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings", action: { showSettings = true })) @@ -546,6 +591,60 @@ struct GamepadHomeView: View { /// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its /// own shelf: the selection already names which card Y was pressed on, and that card's profile /// is what its launches run with. + /// The host menu, built once for all three presentations (the iOS shell layer, the macOS + /// sheet, the tvOS cover) so the actions can't drift between them. + /// + /// Edit REPLACES this menu rather than stacking on it — `hostOptionsTarget` is cleared as + /// `editTarget` is set — which is the desktop console's `Nav::Replace` and what keeps the + /// shell's "depth ≤ 1 by construction" claim true. + @ViewBuilder + private func hostOptionsView(_ target: HostOptionsTarget, active: Bool) -> some View { + let host = target.host + GamepadHostOptionsView( + host: host, + pinnedProfile: target.profile, + isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id), + canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable + && !host.wakeMacs.isEmpty, + onEdit: { + guard !transitioning else { return } + hostOptionsTarget = nil + editTarget = host + }, + onWake: { wakeOnly(host) }, + onForgetPairing: { store.forgetIdentity(host) }, + onRemove: { store.remove(host) }, + onUnpin: { + guard let profile = target.profile else { return } + store.setPinned(host.id, profileID: profile.id, pinned: false) + }, + close: { if !transitioning { hostOptionsTarget = nil } }, + controllerActive: active) + } + + /// The add-host form in edit mode. `store.update` writes the record back by id, so the + /// fingerprint, MACs, pins and binding the form never shows are preserved. + @ViewBuilder + private func editHostView(_ host: StoredHost, active: Bool) -> some View { + GamepadAddHostView( + onAdd: { store.update($0) }, + close: { if !transitioning { editTarget = nil } }, + controllerActive: active, + editingHost: host) + } + + /// UP on a saved tile opens that card's menu. Only SAVED hosts have one: a discovered-but- + /// unsaved host is not ours to rename or remove, and the two action tiles have nothing to + /// offer — the same `HostOptionsScreen::available` gate the desktop console applies. + private func openOptionsForSelected() { + guard case .saved(let id, let profileID) = selection, + let host = store.hosts.first(where: { $0.id == id }) + else { return } + hostOptionsTarget = HostOptionsTarget( + host: host, + profile: profileID.flatMap { pid in profiles.profiles.first { $0.id == pid } }) + } + private func openLibraryForSelected() { guard libraryEnabled, case .saved(let id, let profileID) = selection, let host = store.hosts.first(where: { $0.id == id }) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHostOptionsView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHostOptionsView.swift new file mode 100644 index 00000000..95e11b4b --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHostOptionsView.swift @@ -0,0 +1,321 @@ +// A saved host's own actions — Wake, Copy link, Edit…, Forget pairing, Remove — reached with UP on +// its carousel tile. The console's answer to the overflow menu the touch grid hangs off every host +// card (HostCardView's context menu), and the Apple port of `pf-console-ui`'s HostOptionsScreen. +// +// Until now the gamepad UI could add a host and connect to one, and that was all: a renamed machine +// or a host typed in with a fat-fingered address stayed wrong forever, because the only surface +// that could edit or remove one was the touch UI. The tile is where a host is, so the tile is where +// its actions belong. +// +// UP is the gesture because the carousel is horizontal — left/right are spoken for and up is free — +// and because the desktop console and the Android console already do exactly this, so the three are +// learned once. A pinned profile card offers only Unpin: it is a shortcut, not a second host, and +// offering to remove the host from it would blur precisely the distinction a pin exists to draw. +// +// Vocabulary note: this screen says "Forget pairing" and "Remove host" where the desktop console +// says one word, "Forget". The console has only the one action; Apple has both (HostCardView calls +// them `onForget` = drop the pinned fingerprint and `onRemove` = delete the record), and two +// different actions cannot share a name on the surface that offers both. The touch card's words +// win over the other consoles' here — a user meets both Apple surfaces, and only one of them is +// cross-platform. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) || os(tvOS) + +/// Which card the menu was opened on. Carries the host BY VALUE for the same reason the screen +/// does — the carousel is rebuilt on every discovery pass, and a target that re-resolved itself +/// could hand "Remove" a different host than the one the user was looking at. +struct HostOptionsTarget: Identifiable { + let host: StoredHost + /// Non-nil ⇒ a pinned profile card rather than the host's own tile. + var profile: StreamProfile? + + /// Keyed on the CARD, not the host: a host and each of its pinned cards open different menus, + /// and sharing an id would let one stand in for another mid-transition (the same rule + /// `GamepadScreen.library` follows). + var id: String { "\(host.id.uuidString)-\(profile?.id ?? "")" } +} + +struct GamepadHostOptionsView: View { + /// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that + /// value itself and so sits above its own copy (see `GamepadInk.stored`). + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + private var ink: GamepadInk { .stored(paletteID) } + @Environment(\.gamepadMetrics) private var metrics + @Environment(\.displayBottomInset) private var displayBottomInset + @Environment(\.gamepadHostedInShell) private var hostedInShell + @Environment(\.dismiss) private var dismiss + + /// The host this menu was opened on, BY VALUE. Discovery rewrites the carousel on every + /// service pass; holding an index or a live lookup would let the menu retarget itself onto + /// whichever host slid into that slot, and "Remove" must never be able to do that. + let host: StoredHost + /// Non-nil ⇒ opened on a pinned profile card rather than the host's own tile. + var pinnedProfile: StreamProfile? + /// Whether the host is reachable right now — decides whether Wake is worth offering. + var isOnline = false + /// Whether waking is possible at all (the setting is on, WoL is available, a MAC is known). + var canWake = false + let onEdit: () -> Void + let onWake: () -> Void + /// Drop the pinned fingerprint — the host stays saved, and the next connect re-pairs. + let onForgetPairing: () -> Void + /// Delete the saved record outright. + let onRemove: () -> Void + let onUnpin: () -> Void + var close: (() -> Void)? + var controllerActive = true + + #if os(iOS) + @Environment(\.verticalSizeClass) private var vSizeClass + + private var compact: Bool { vSizeClass == .compact } + #else + private let compact = false + #endif + + /// Removing is the one action here with no undo, so its row ARMS on the first press and only + /// fires on the second. The touch grid removes behind a system confirmation dialog; a console + /// is driven by a thumbstick from across a room, which is a good reason to be at least as + /// strict as it is, and none at all to be looser. + @State private var armed = false + @State private var copied = false + @State private var focusID: String? + + private enum Action: String { + case wake + case copyLink + case edit + case forgetPairing + case remove + case unpin + case cancel + } + + var body: some View { + GamepadMenuList( + items: rows, + focusID: $focusID, + onActivate: { run($0.action) }, + onBack: { performClose() }, + isActive: controllerActive + ) { row, focused in + rowView(row, focused: focused) + .frame(maxWidth: metrics.rowMaxWidth) + .padding(.horizontal, 24) + } + .frame(maxWidth: .infinity) + .safeAreaInset(edge: .top, spacing: 0) { + VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) { + Text(title) + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) + .foregroundStyle(ink.fg) + .lineLimit(1) + if !compact { + Text("\(host.address):\(String(host.port))") + .font(.geistFixed(metrics.detailFont, .medium)) + .foregroundStyle(ink.fg(0.55)) + } + } + .padding(.horizontal, 24) + .padding(.top, gamepadTitleTopPadding(compact: compact)) + .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) + .frame(maxWidth: .infinity, alignment: .leading) + .background { GamepadTrayBlur(edge: .top) } + } + .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + Text(detail) + .font(.geist(metrics.detailFont, relativeTo: .caption)) + .foregroundStyle(ink.fg(0.55)) + .lineLimit(2, reservesSpace: true) + .animation(.smooth(duration: 0.2), value: focusID) + GamepadHintBar(hints: hints) + } + .padding(.leading, compact ? 12 : 18) + .padding(.trailing, 22) + .padding( + .bottom, + gamepadLegendBottomPadding( + compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset)) + .padding(.top, compact ? 6 : 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background { GamepadTrayBlur(edge: .bottom) } + } + .background { + if !hostedInShell { GamepadFormBackground() } + } + .gamepadPaletteInk() + // Moving the focus off the armed Remove row disarms it: an arming that outlives the row it + // was made on is a trap, and the thumb that wandered away is exactly the hesitation the + // two-press rule exists to catch. + .onChange(of: focusID) { _, id in + if id != Action.remove.rawValue { armed = false } + } + #if !os(tvOS) + .background { + Button("Cancel") { performClose() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + #endif + } + + private var title: String { + pinnedProfile.map { "\(host.displayName) · \($0.name)" } ?? host.displayName + } + + // MARK: - Rows + + private struct Row: Identifiable { + let action: Action + let label: String + var icon: String + var isDestructive = false + var id: String { action.rawValue } + } + + private var rows: [Row] { + // A pinned card is a shortcut, not a host: everything host-level is deliberately absent. + if pinnedProfile != nil { + return [ + Row(action: .unpin, label: "Unpin card", icon: "pin.slash"), + Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"), + Row(action: .cancel, label: "Cancel", icon: "xmark"), + ] + } + var list: [Row] = [] + // Waking a host that is already answering would just sit there counting seconds. + if canWake, !isOnline { + list.append(Row(action: .wake, label: "Wake host", icon: "power")) + } + list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link")) + list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil")) + // Only a paired host has a pairing to drop. + if host.pinnedSHA256 != nil { + list.append(Row( + action: .forgetPairing, label: "Forget pairing", icon: "lock.open")) + } + list.append(Row( + action: .remove, + label: armed ? "Remove host \u{2014} press again" : "Remove host", + icon: "trash", isDestructive: true)) + list.append(Row(action: .cancel, label: "Cancel", icon: "xmark")) + return list + } + + /// The explainer under the list — the same band the settings screen uses, and the only place a + /// destructive action can say what it will actually do before it is pressed. + private var detail: String { + switch rows.first(where: { $0.id == focusID })?.action { + case .wake: + return "Send a Wake-on-LAN packet and wait for this host to answer." + case .copyLink: + return "Copy a punktfunk:// link to this host — paste it anywhere to connect." + case .edit: + return "Rename this host or change its address. Pairing and pinned cards are kept." + case .forgetPairing: + return "Drop the stored fingerprint. The host stays saved and the next connect " + + "pairs again." + case .remove: + return armed + ? "Press again to remove — this cannot be undone." + : "Delete this host, its pairing and its pinned cards from this device." + case .unpin: + return "Remove this profile's card. The profile itself and the host are untouched." + case .cancel, .none: + return "" + } + } + + private var hints: [GamepadHint] { + [ + .init( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select", + action: { if let id = focusID, let row = rows.first(where: { $0.id == id }) { + run(row.action) + } }), + .init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back", + action: { performClose() }), + ] + } + + // MARK: - Actions + + private func run(_ action: Action) { + switch action { + case .wake: + onWake() + performClose() + case .copyLink: + LinkClipboard.copy( + DeepLink.forHost(host, profile: pinnedProfile?.id).urlString) + // No toast machinery on this surface — the row says so itself, which is the same + // acknowledgement in the place the user is already looking. + withAnimation(.smooth(duration: 0.2)) { copied = true } + case .edit: + onEdit() + case .forgetPairing: + onForgetPairing() + performClose() + case .remove: + guard armed else { + withAnimation(.smooth(duration: 0.2)) { armed = true } + return + } + onRemove() + performClose() + case .unpin: + onUnpin() + performClose() + case .cancel: + performClose() + } + } + + private func performClose() { + if let close { close() } else { dismiss() } + } + + // MARK: - Row rendering + + private func rowView(_ row: Row, focused: Bool) -> some View { + let m = metrics + // The destructive row wears the warning colour only once ARMED: red on a row that still + // needs a second press reads as "this already happened". + let danger = row.isDestructive && armed + return HStack(spacing: 14) { + Image(systemName: row.icon) + .font(.system(size: m.iconFont)) + .foregroundStyle( + danger ? GamepadInk.warningRed : (focused ? ink.accent : ink.fg(0.55))) + .frame(width: m.iconWidth) + Text(row.label) + .font(.geist(m.labelFont, .semibold, relativeTo: .body)) + .foregroundStyle(danger ? GamepadInk.warningRed : ink.fg) + .lineLimit(1) + Spacer(minLength: 12) + } + .padding(.horizontal, m.rowHPad) + .padding(.vertical, m.rowVPad) + .consoleGlass( + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), + tint: focused ? (danger ? GamepadInk.warningRed.opacity(0.3) : ink.accent(0.30)) : nil, + interactive: focused) + .overlay { + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) + .strokeBorder( + danger ? GamepadInk.warningRed.opacity(0.7) : ink.fg(focused ? 0.28 : 0.06), + lineWidth: 1) + } + .scaleEffect(focused ? 1.0 : 0.98) + .animation(.smooth(duration: 0.18), value: focused) + .animation(.smooth(duration: 0.18), value: armed) + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift index 07e93190..71486e1b 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift @@ -88,6 +88,11 @@ struct GamepadInk: Equatable, Sendable { /// The online pip — deliberately NOT palette-derived: a status colour must not change /// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim). static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29) + /// An armed destructive action (the host menu's Remove). Palette-independent for exactly the + /// same reason as the pip above, and the more strongly so: the one colour on this UI that + /// means "this does not come back" cannot be allowed to drift toward the wallpaper on a warm + /// palette, or read as a highlight on a red one. + static let warningRed = Color(red: 0.94, green: 0.28, blue: 0.26) } private struct GamepadInkKey: EnvironmentKey { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift index 0e6bcf18..48b520d1 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift @@ -21,6 +21,8 @@ import SwiftUI enum GamepadScreen: Identifiable { case settings case addHost + case hostOptions(HostOptionsTarget) + case editHost(StoredHost) case pair(StoredHost) case library(LibraryTarget) @@ -28,6 +30,10 @@ enum GamepadScreen: Identifiable { switch self { case .settings: return "settings" case .addHost: return "addHost" + // Keyed on the CARD (host + pinned profile), for the same reason the library is keyed on + // the shelf — see `HostOptionsTarget.id`. + case .hostOptions(let target): return "hostOptions-\(target.id)" + case .editHost(let host): return "editHost-\(host.id.uuidString)" case .pair(let host): return "pair-\(host.id.uuidString)" // Keyed on the SHELF, not the host: a host and each of its pinned cards open different // libraries, and sharing an id would let one stand in for another mid-transition. @@ -39,7 +45,7 @@ enum GamepadScreen: Identifiable { /// (`Bg::Form` in the console); the library keeps the launcher's full aurora. var isForm: Bool { switch self { - case .settings, .addHost, .pair: return true + case .settings, .addHost, .hostOptions, .editHost, .pair: return true case .library: return false } } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 1691ef31..fd2f3b5a 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -244,7 +244,8 @@ private struct ShotGamepadHome: View { store: store, model: model, discovery: discovery, libraryTarget: .constant(nil), pairingTarget: .constant(nil), onPaired: { _, _ in }, waker: waker, - connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in }) + connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in }, + wakeOnly: { _ in }) } } @@ -303,7 +304,8 @@ private struct ShotConnect: View { store: store, model: model, discovery: discovery, libraryTarget: .constant(nil), pairingTarget: .constant(nil), onPaired: { _, _ in }, waker: waker, - connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in }) + connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in }, + wakeOnly: { _ in }) } else { ShotHome() } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift b/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift index 8b3bba9a..2ef125c1 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift @@ -28,6 +28,10 @@ struct AboutView: View { #if !os(tvOS) @State private var showAcknowledgements = false + /// The in-session controls. They used to announce themselves in a 6-second banner at the start + /// of every stream; that banner is gone, so this page is where they live now — including for + /// touch users on a Mac, who saw it too. + @State private var showShortcuts = false #endif var body: some View { @@ -44,6 +48,9 @@ struct AboutView: View { .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) } + Section { + shortcutsRow + } Section { linkRow("Documentation", systemImage: "book", url: Destination.docs) linkRow("Community", systemImage: "bubble.left.and.bubble.right", @@ -63,6 +70,21 @@ struct AboutView: View { // A SHEET, not a push — on iPad the settings detail column is deliberately not a // NavigationStack (an inner one doubles the title bar), so a NavigationLink from here // pushed into a context with no back button and stranded the licenses on screen. + // A sheet for the same reason Acknowledgements is one — see that modifier's note on the + // iPad detail column not being a NavigationStack. + .sheet(isPresented: $showShortcuts) { + NavigationStack { + ShortcutsView(micAvailable: ShortcutsCatalog.micPlausible) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showShortcuts = false } + } + } + } + #if os(macOS) + .frame(width: 560, height: 460) + #endif + } .sheet(isPresented: $showAcknowledgements) { NavigationStack { AcknowledgementsView() @@ -135,6 +157,24 @@ struct AboutView: View { .foregroundStyle(.primary) } + private var shortcutsRow: some View { + Button { + showShortcuts = true + } label: { + HStack { + Label("Shortcuts", systemImage: "command") + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + } + private var acknowledgementsRow: some View { Button { showAcknowledgements = true @@ -168,6 +208,11 @@ struct AboutView: View { tvAddress("Community", Destination.community) tvAddress("Source code", Destination.source) } + // Both push here: this page really is inside a navigation stack on tvOS, which is + // the case the sheets above exist to work around elsewhere. + NavigationLink("Shortcuts") { + ShortcutsView(micAvailable: false) // tvOS has no app-accessible mic + } NavigationLink("Acknowledgements") { AcknowledgementsView() } Text("Punktfunk's source is open under MIT or Apache-2.0.") .font(.geist(20, relativeTo: .caption)) @@ -219,21 +264,39 @@ struct AppIconView: View { var body: some View { Group { if let icon = Self.bundleIcon { - icon.image - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - // iOS ships the icon UNMASKED — the springboard applies the rounded shape at - // draw time, so used raw it is a hard-cornered square. macOS bakes its own - // shape (and margins) into the image, and clipping that would cut into it. - .clipShape(RoundedRectangle( - cornerRadius: icon.needsMask ? side * Self.iOSCornerRatio : 0, - style: .continuous)) + // The mask is applied ONLY where it is wanted. A `cornerRadius: 0` RoundedRectangle + // is not a no-op — it still clips to the layout frame, which crops any art whose + // aspect ratio isn't the frame's (the TV's 400x240 icon lost its ends to it). + // iOS ships the icon UNMASKED — the springboard applies the rounded shape at draw + // time, so used raw it is a hard-cornered square. macOS bakes its own shape (and + // margins) into the image, and clipping that would cut into it. + if icon.needsMask { + icon.image + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle( + cornerRadius: side * Self.iOSCornerRatio, style: .continuous)) + } else { + icon.image + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + } } else { monogram } } + // tvOS's icon is a 400×240 rectangle, not a squircle — framing it square would letterbox + // it inside a box two thirds empty. `side` means HEIGHT there, and the width follows the + // real 5:3 art. A MAX frame rather than a fixed one: with a fixed width the image cannot + // shrink when its row is tight, so it overflows and is clipped by whatever is above it + // instead — `.fit` inside a max frame gives back the whole icon, just smaller. + #if os(tvOS) + .frame(maxWidth: side * (400.0 / 240.0), maxHeight: side) + #else .frame(width: side, height: side) + #endif .accessibilityHidden(true) // the app's name is the next line } @@ -267,7 +330,14 @@ struct AppIconView: View { else { return nil } return (Image(uiImage: image), true) #else - return nil // tvOS: layered icons have no single image to load + // tvOS ships the icon as a parallax image STACK (Back/Circle1/Circle2/Front), which has + // no single image to load — which is why this used to return nil and every About page on + // the TV drew the "P" monogram instead of the app's own mark. `AboutAppIcon` is those + // four layers flattened into one asset, generated from the SAME art the stack uses so it + // cannot drift into being a second, subtly different icon. Already masked and composited, + // so it needs no rounding of ours. + guard let image = UIImage(named: "AboutAppIcon") else { return nil } + return (Image(uiImage: image), false) #endif } } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 81d280c1..562d56e0 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -42,6 +42,9 @@ enum GpSettingsTab: String, CaseIterable, Hashable { case controller = "Controller" case interface = "Interface" case profiles = "Profiles" + /// Trailing, like Profiles: both are built from something other than the settings store, and + /// About is where the strip ends because it is the one section that changes nothing. + case about = "About" } struct GamepadSettingsView: View { @@ -53,6 +56,8 @@ struct GamepadSettingsView: View { @Environment(\.displayBottomInset) private var displayBottomInset @Environment(\.dismiss) private var dismiss @Environment(\.gamepadHostedInShell) private var hostedInShell + /// The About section's link rows (never used on tvOS, which has no browser). + @Environment(\.openURL) private var openURL /// The saved-host store — the pin picker writes `setPinned` through it and the profile rows /// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen /// itself (ContentView owns the instance). @@ -64,6 +69,9 @@ struct GamepadSettingsView: View { /// console's input drop) and while the connect takeover is up; a system presentation never /// needs the gate and keeps the default. var controllerActive = true + /// Whether this device has a microphone at all — passed through to the About page's shortcuts + /// reference, which must not list a mute key on a device that can't mute anything. + var micAvailable = true @AppStorage(DefaultsKey.streamWidth) private var width = 1920 @AppStorage(DefaultsKey.streamHeight) private var height = 1080 @AppStorage(DefaultsKey.streamHz) private var hz = 60 @@ -135,6 +143,14 @@ struct GamepadSettingsView: View { /// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the /// changed value slides in from, so the animation follows the user's motion. @State private var lastAdjustDelta = 1 + /// A reading surface opened from the About tab, replacing the row list the way the pin picker + /// does. Depth is 1: neither page opens anything further. + private enum AboutPage: Equatable { + case shortcuts + case licenses + } + + @State private var aboutPage: AboutPage? var body: some View { GamepadMenuList( @@ -160,9 +176,9 @@ struct GamepadSettingsView: View { .foregroundStyle(ink.fg) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) - // The picker is one layer deeper — its rows aren't sections of anything, so the - // strip would be a control that does nothing while it's up. - if pinTarget == nil { tabStrip } + // The picker and the About reading pages are one layer deeper — their rows aren't + // sections of anything, so the strip would be a control that does nothing. + if pinTarget == nil, aboutPage == nil { tabStrip } } .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) @@ -329,16 +345,62 @@ struct GamepadSettingsView: View { if let close { close() } else { dismiss() } } + /// Where the product actually lives — kept together so the three can be checked against the + /// README in one glance (the touch `AboutView` holds the same three). + private enum Destination { + static let docs = URL(string: "https://docs.punktfunk.unom.io")! + static let community = URL(string: "https://discord.gg/kaPNvzMuGU")! + static let source = URL(string: "https://git.unom.io/unom/punktfunk")! + } + + /// "Version 0.29.0 (100000)" — the build number only when it says something the version does + /// not. Mirrors `AboutView.versionLine`; a bug report is worth more with it. + private static var versionLine: String { + let info = Bundle.main.infoDictionary + let short = info?["CFBundleShortVersionString"] as? String ?? "—" + let build = info?["CFBundleVersion"] as? String + guard let build, !build.isEmpty, build != short else { return "Version \(short)" } + return "Version \(short) (\(build))" + } + /// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which /// layer the row list currently is. private var title: String { - pinTarget.map { "Pin “\($0.name)”" } ?? "Settings" + if let profile = pinTarget { return "Pin “\(profile.name)”" } + switch aboutPage { + case .shortcuts: return "Shortcuts" + case .licenses: return "Acknowledgements" + case nil: return "Settings" + } } /// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the /// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one /// layer" rule), and a hostless picker has nothing to pin, so only Back remains. private var hints: [GamepadHint] { + // A reading page is scrolled, not operated: offering A would be the same lie a dimmed row + // used to tell. Only Back remains. + if aboutPage != nil { + return [.init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back", + action: { back() })] + } + // The About rows open things rather than change them, so A reads "Open" and there is no + // Adjust cell — left/right genuinely does nothing there. + if pinTarget == nil, tab == .about { + let sections: [GamepadHint] = showsSectionHint + ? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"), + text: "Section", action: { step(tabBy: 1) })] + : [] + return sections + [ + .init( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Open", + action: { if let focusID { activate(id: focusID) } }), + .init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done", + action: { back() }), + ] + } guard pinTarget != nil else { // The shoulders change section, so that cell leads — where it fits and where the // shoulders exist at all (see `showsSectionHint`). @@ -386,6 +448,9 @@ struct GamepadSettingsView: View { if let profile = pinTarget { pinTarget = nil focusID = "profile-\(profile.id)" + } else if let page = aboutPage { + aboutPage = nil + focusID = page == .shortcuts ? "shortcuts" : "licenses" } else { performClose() } @@ -393,7 +458,53 @@ struct GamepadSettingsView: View { // MARK: - Row rendering + @ViewBuilder private func rowView(_ row: Row, focused: Bool) -> some View { + switch row.kind { + case .control: controlRow(row, focused: focused) + case .footer: + Text(row.label) + .font(.geist(metrics.detailFont, .medium, relativeTo: .caption)) + .monospacedDigit() + .foregroundStyle(ink.fg(focused ? 0.7 : 0.45)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 18) + .animation(.smooth(duration: 0.18), value: focused) + case .heading: + Text(row.label) + .font(.geist(metrics.labelFont, .bold, relativeTo: .headline)) + .foregroundStyle(ink.fg(0.75)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, metrics.rowHPad) + .padding(.top, 14) + .padding(.bottom, 2) + case .prose: + // Focus here means "this is the part you are scrolled to", not "press A" — so it is a + // quiet wash rather than the control rows' full glass. + VStack(alignment: .leading, spacing: 4) { + Text(row.label) + .font(.geistFixed(metrics.valueFont, .medium)) + .foregroundStyle(ink.fg(0.95)) + .fixedSize(horizontal: false, vertical: true) + if !row.value.isEmpty { + Text(row.value) + .font(.geist(metrics.detailFont, relativeTo: .caption)) + .foregroundStyle(ink.fg(0.6)) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, metrics.rowHPad) + .padding(.vertical, metrics.rowVPad * 0.7) + .background { + RoundedRectangle(cornerRadius: metrics.rowCorner, style: .continuous) + .fill(ink.fg(focused ? 0.08 : 0)) + } + .animation(.smooth(duration: 0.18), value: focused) + } + } + + private func controlRow(_ row: Row, focused: Bool) -> some View { let m = metrics // No section header: the tab strip names the section now, and repeating it above the // first row of every tab was just a second label saying the same word. @@ -505,10 +616,23 @@ struct GamepadSettingsView: View { /// `activate(id:)`, not per closure, so no row builder can forget it. /// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.) var enabled = true + /// How this row DRAWS. Every tab but About is `.control` — the glass row with a label and + /// a value. About is a reading surface as much as a menu, so it also has a heading and a + /// block of prose, which are rows only so the focus list can scroll them (the same trick + /// `Licenses.chunked` plays for tvOS focus). + var kind: Kind = .control /// Left/right step; returns whether the value actually changed (false ⇒ boundary thud). let adjust: (Int) -> Bool /// A — cycle forward (wrapping) / flip. let activate: () -> Void + + enum Kind { + case control + case heading + case prose + /// Quiet, centred trailing text — the About tab's version line. + case footer + } } /// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows @@ -530,9 +654,133 @@ struct GamepadSettingsView: View { /// controller wiring and the tvOS focus engine carry over as is). private var rows: [Row] { if let profile = pinTarget { return pinRows(for: profile) } + if let page = aboutPage { + switch page { + case .shortcuts: return shortcutRows + case .licenses: return licenseRows + } + } + if tab == .about { return aboutRows } return allRows.filter { $0.tab == tab } } + // MARK: - About + + /// The About section: the ways out, plus the two reading surfaces. The identity itself (icon, + /// name, version, tagline) is the HEADER while this tab is up — see `aboutIdentity` — not a + /// row, so the list holds no focus stop that does nothing when pressed. + private var aboutRows: [Row] { + var list: [Row] = [ + aboutAction( + id: "shortcuts", icon: "command", label: "Shortcuts", value: "While streaming", + detail: "What to press during a session on this device — and on a controller.", + open: .shortcuts), + aboutAction( + id: "licenses", icon: "text.document", label: "Acknowledgements", + value: "MIT or Apache-2.0", + detail: "Punktfunk's own licence and the third-party components it uses.", + open: .licenses), + ] + list.append(contentsOf: [ + aboutLink(id: "docs", icon: "book", label: "Documentation", url: Destination.docs), + aboutLink( + id: "community", icon: "bubble.left.and.bubble.right", label: "Community", + url: Destination.community), + aboutLink( + id: "source", icon: "chevron.left.forwardslash.chevron.right", + label: "Source code", url: Destination.source), + ]) + // The version sits UNDER the rows rather than in a header card above them. The card that + // used to head this tab carried the app icon, and on tvOS that icon is a 400x240 + // rectangle that would not survive contact with a layout built for square art — three + // attempts at framing it were still cropping it on the real TV. A version string answers + // the only question anyone actually opens About to ask, and has no aspect ratio to get + // wrong. `.footer` draws it quiet and centred, so it reads as a footer and not a row you + // failed to press. + list.append(Row( + id: "version", tab: .about, icon: "", label: Self.versionLine, value: "", + detail: "", adjustable: false, enabled: true, kind: .footer, + adjust: { _ in false }, activate: {})) + return list + } + + private func aboutAction( + id: String, icon: String, label: String, value: String, detail: String, open: AboutPage + ) -> Row { + Row( + id: id, tab: .about, icon: icon, label: label, value: value, detail: detail, + adjustable: false, + adjust: { _ in false }, + activate: { + // Focus lands on the page's first row — the focus list's reconcile follows this + // id when the row set swaps underneath it (the pin picker's pattern). + focusID = open == .shortcuts ? shortcutRows.first?.id : licenseRows.first?.id + aboutPage = open + }) + } + + /// tvOS has no browser and no `openURL`, so an address there is text to read off the screen + /// rather than a link to nowhere — the same call the touch About page makes. + private func aboutLink(id: String, icon: String, label: String, url: URL) -> Row { + let shown = url.absoluteString.replacingOccurrences(of: "https://", with: "") + #if os(tvOS) + return Row( + id: id, tab: .about, icon: icon, label: label, value: shown, + detail: "Open this address on a phone or computer.", + adjustable: false, adjust: { _ in false }, activate: {}) + #else + return Row( + id: id, tab: .about, icon: icon, label: label, value: shown, + detail: "Opens in your browser.", + adjustable: false, adjust: { _ in false }, activate: { openURL(url) }) + #endif + } + + /// The shortcuts reference — the same `ShortcutsCatalog` the touch About page renders, so the + /// two can never drift. + private var shortcutRows: [Row] { + ShortcutsCatalog.groups(micAvailable: micAvailable).flatMap { group -> [Row] in + [aboutText(id: "group-\(group.title)", label: group.title, kind: .heading)] + + group.items.map { item in + aboutText( + id: "sc-\(group.title)-\(item.keys)", label: item.keys, value: item.text, + kind: .prose) + } + } + } + + /// The licence wall, one row per pre-chunked page (`Licenses.chunked`, which exists so tvOS + /// can page it by focus steps) — so it scrolls with the stick and needs no machinery here. + private var licenseRows: [Row] { + var list: [Row] = [ + aboutText(id: "lic-heading", label: "Punktfunk", kind: .heading), + aboutText( + id: "lic-summary", + label: "Punktfunk's source is open under MIT or Apache-2.0. It ships the Geist " + + "typeface under the SIL Open Font License 1.1, and uses the third-party " + + "components below, each under its own license.", + kind: .prose), + ] + for (i, chunk) in Licenses.chunked(Licenses.appLicense).enumerated() { + list.append(aboutText(id: "lic-app-\(i)", label: chunk, kind: .prose)) + } + list.append(aboutText( + id: "lic-third-heading", label: "Third-party software", kind: .heading)) + for (i, chunk) in Licenses.thirdPartyNoticesChunks.enumerated() { + list.append(aboutText(id: "lic-third-\(i)", label: chunk, kind: .prose)) + } + return list + } + + private func aboutText( + id: String, label: String, value: String = "", kind: Row.Kind + ) -> Row { + Row( + id: id, tab: .about, icon: "", label: label, value: value, detail: "", + adjustable: false, enabled: true, kind: kind, + adjust: { _ in false }, activate: {}) + } + /// Every row on the screen, tagged with its section. Built as one list (not per tab) so the /// platform-conditional insertions below can still place a row RELATIVE to another by id. private var allRows: [Row] { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/ShortcutsReference.swift b/clients/apple/Sources/PunktfunkClient/Settings/ShortcutsReference.swift new file mode 100644 index 00000000..2c975cf7 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/ShortcutsReference.swift @@ -0,0 +1,181 @@ +// The in-session controls, written down once and read by every surface that shows them. +// +// This replaced the start-of-stream banner (ContentView's `showShortcutHint`): a 6-second pill +// that told you the controls exactly once, while you were busy looking at the thing you had just +// connected to, and then never again. A reference you can OPEN answers the question at the moment +// it is actually asked — which is the second session, not the first. +// +// The catalog is data rather than a view so both About pages render the same words: the touch +// `AboutView` (a Form) and the controller-first `GamepadAboutView` (a console list). The banner +// was macOS/tvOS-only, so deleting it would have cost Mac TOUCH users the one place those keys +// were written down — hence the touch surface gets this too, not just the gamepad UI. +// +// Per-platform by `#if`, because the honest answer really is different: tvOS has no keyboard and +// no menu bar, iOS has a touch gesture nothing else has, and macOS is the only one that has to +// explain mouse capture. A controller's chords are the one section common to all three — they are +// the same buttons on every client (`GamepadCapture.escapeChord` / `.statsChord`), which is the +// whole point of a cross-client chord. + +import AVFoundation +import PunktfunkKit +import SwiftUI + +/// One line of the reference: what you press, and what it does. +struct ShortcutItem: Identifiable { + /// Stable within its group — the keys are unique per group by construction. + var id: String { keys } + /// The chord itself, rendered monospaced so ⌃⌥⇧-style runs stay legible. + let keys: String + let text: String +} + +struct ShortcutGroup: Identifiable { + var id: String { title } + let title: String + let items: [ShortcutItem] +} + +enum ShortcutsCatalog { + /// Whether a mute key is worth listing when no session is running, for the About page reached + /// from settings. `SessionModel.micAvailable` is the authority DURING a session — it also + /// consults the profile the session actually resolved — but a reference page opened between + /// sessions has no session to ask, so it answers the device-level half of the same question: + /// a platform with an app-accessible input, the mic setting on, and the OS not refusing. + /// `.notDetermined` counts, exactly as it does there: the prompt is simply still pending. + static var micPlausible: Bool { + #if os(tvOS) + return false // no app-accessible microphone + #else + guard UserDefaults.standard.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true + else { return false } + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized, .notDetermined: return true + default: return false + } + #endif + } + + /// `micAvailable` gates the mute row — a device with no microphone would otherwise be told + /// about a key that does nothing, which is the failure the old banner already avoided. + static func groups(micAvailable: Bool) -> [ShortcutGroup] { + var groups: [ShortcutGroup] = [] + #if os(macOS) + var keyboard: [ShortcutItem] = [ + .init(keys: "Click", text: "Capture the mouse and keyboard for the stream"), + .init(keys: "⌃⌥⇧Q", text: "Release the mouse and keyboard back to this Mac"), + .init(keys: "⌃⌥⇧D", text: "Disconnect"), + .init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"), + ] + if micAvailable { + keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone")) + } + groups.append(.init(title: "Keyboard", items: keyboard)) + #elseif os(iOS) + // iPad with a hardware keyboard gets the same cross-client set as the Mac (StreamCommands + // publishes it either way); a phone simply never sees a keyboard to press it on. + var keyboard: [ShortcutItem] = [ + .init(keys: "⌃⌥⇧Q", text: "Release the pointer back to this device"), + .init(keys: "⌃⌥⇧D", text: "Disconnect"), + .init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"), + ] + if micAvailable { + keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone")) + } + groups.append(.init(title: "Hardware keyboard", items: keyboard)) + groups.append(.init(title: "Touch", items: [ + .init(keys: "Three-finger tap", text: "Cycle the statistics overlay"), + ])) + #elseif os(tvOS) + // The remote section leads on tvOS: it carries the ONLY exits. Menu/B is swallowed during + // a session (ContentView's `.onExitCommand {}`), so a user who does not know the hold + // gesture is genuinely stuck — which is why this was the one banner that could not simply + // be deleted without putting the words somewhere findable first. + groups.append(.init(title: "Siri Remote", items: [ + .init(keys: "Hold Back", text: "Disconnect"), + .init(keys: "Touch surface", text: "Move the pointer"), + .init(keys: "Press", text: "Click"), + .init(keys: "Play/Pause", text: "Right-click"), + .init(keys: "Hold Play/Pause", text: "Cycle the statistics overlay"), + ])) + #endif + // Every client's controller speaks these two chords — see GamepadCapture.escapeChord and + // .statsChord, which a test pins against their GameController element lists. + groups.append(.init(title: "Controller", items: [ + .init(keys: "L1 + R1 + Start + Select", text: "Hold to disconnect"), + .init(keys: "Select + X", text: "Cycle the statistics overlay"), + .init(keys: "Hold Select", text: "Press the host's guide button"), + ])) + return groups + } +} + +/// The standard-interface reference — a sheet from `AboutView` on iOS/macOS, a pushed page on +/// tvOS — so the keys the start-of-stream banner used to carry are still one press away. +/// (The controller-first surface renders the same catalog itself; see `GamepadAboutView`.) +struct ShortcutsView: View { + let micAvailable: Bool + + var body: some View { + #if os(tvOS) + // No `Form`/`.formStyle(.grouped)` worth using at 10 feet, and the rows are read, not + // operated — a plain scrolling column at TV sizes says the same thing with less chrome. + ScrollView { + VStack(alignment: .leading, spacing: 30) { + ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in + VStack(alignment: .leading, spacing: 12) { + Text(group.title) + .font(.geist(28, .semibold, relativeTo: .headline)) + ForEach(group.items) { item in + HStack(alignment: .firstTextBaseline, spacing: 20) { + Text(item.keys) + .font(.geistFixed(22, .medium)) + .frame(minWidth: 300, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + Text(item.text) + .font(.geist(22, relativeTo: .caption)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + .frame(maxWidth: 1000, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(60) + } + .navigationTitle("Shortcuts") + #else + form + #endif + } + + #if !os(tvOS) + private var form: some View { + Form { + ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in + Section(group.title) { + ForEach(group.items) { item in + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(item.keys) + .font(.geistFixed(13, .medium)) + .foregroundStyle(.primary) + // A fixed column keeps the descriptions aligned; the chords vary + // from "Click" to "L1 + R1 + Start + Select". + .frame(minWidth: 132, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + Text(item.text) + .font(.geist(13, relativeTo: .footnote)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 2) + } + } + } + } + .formStyle(.grouped) + .navigationTitle("Shortcuts") + } + #endif +}