From 96278eebb5e0ae484ebfbb00c1e0e838c1b9d675 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 22:34:46 +0200 Subject: [PATCH 1/5] feat(apple/gamepad-ui): the host tile gets a menu, and the start-of-stream banner becomes a page you can open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gamepad UI could add a host and connect to one, and that was all: a renamed machine or a fat-fingered address stayed wrong forever, because the only surface that could edit or remove one was the touch UI. The desktop console and the Android console have both had a host menu on UP for a while — this is the Apple port of it, so the three consoles are learned once. UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove. Wiring UP takes the whole vertical axis away from scrolling (down goes inert): a horizontal carousel has no vertical travel to spend, and one meaning per direction is what makes the gesture learnable. Remove arms on the first press and only fires on the second, and disarms if focus wanders off the row — the touch grid gets a system confirmation dialog, and a thumbstick from across a room is a good reason to be at least as strict. A pinned profile card offers only Unpin: it is a shortcut, not a second host. Edit reuses GamepadAddHostView, seeded from the record and writing a COPY back through HostStore.update, so the fingerprint, MACs, pins and binding the form never shows survive a rename. It REPLACES the menu rather than stacking on it, which keeps the shell's "depth <= 1 by construction" true. This also retires the start-of-stream shortcut banner. Telling someone the controls for six seconds, over the stream they have just connected to, answers the question at the one moment nobody is asking it — and it put a composited overlay above the stream to do it. The words are now ShortcutsCatalog, rendered by an About page on BOTH surfaces: the new gamepad one (icon, version, licenses, shortcuts) and the touch AboutView. The touch half is not a bonus — the banner fired in touch mode on a Mac too, so deleting it without that would have cost those users the only place the keys were written down. Verified: swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0 (the iOS pass is what typechecks the shell-layer code, which is #if os(iOS)); 288 tests pass. NOT verified on glass — screen capture is unavailable in this environment, so the new screens have been compiled and reasoned about but not seen. --- .../Sources/PunktfunkClient/ContentView.swift | 66 +-- .../Home/GamepadAddHostView.swift | 55 ++- .../Home/GamepadCarousel.swift | 26 ++ .../Home/GamepadHomeView.swift | 105 ++++- .../Home/GamepadHostOptionsView.swift | 321 ++++++++++++++ .../PunktfunkClient/Home/GamepadInk.swift | 5 + .../PunktfunkClient/Home/GamepadShell.swift | 8 +- .../Screenshots/ScreenshotScenes.swift | 6 +- .../PunktfunkClient/Settings/AboutView.swift | 45 ++ .../Settings/GamepadAboutView.swift | 406 ++++++++++++++++++ .../Settings/GamepadSettingsView.swift | 35 ++ .../Settings/ShortcutsReference.swift | 181 ++++++++ 12 files changed, 1193 insertions(+), 66 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Home/GamepadHostOptionsView.swift create mode 100644 clients/apple/Sources/PunktfunkClient/Settings/GamepadAboutView.swift create mode 100644 clients/apple/Sources/PunktfunkClient/Settings/ShortcutsReference.swift 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..a022e31c 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)) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadAboutView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadAboutView.swift new file mode 100644 index 00000000..e9ce3bfd --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadAboutView.swift @@ -0,0 +1,406 @@ +// The gamepad UI's About page — the controller-first counterpart of `AboutView`, reached from the +// settings screen's About tab. Same content in the same order as the touch page (icon, name, +// version, then the ways out: documentation, community, source, licenses), restyled as a console +// screen and navigable with a stick: the identity card is the header, everything else is a row. +// +// It also carries the SHORTCUTS reference, which is why this page exists now rather than whenever +// an About page happened to get written. The controls used to be announced by a 6-second banner at +// the start of every session (ContentView's `showShortcutHint`) — a message that interrupts you +// once, while you are looking at the stream you just opened, and is unavailable forever after. +// Moving the words here trades a notification for a destination: nothing flashes over the stream, +// and the answer is reachable at the moment the question is actually asked. +// +// Both sub-pages are IN-PLACE layers, not pushes — the same "one layer, B peels it back" rule the +// settings pin picker and GamepadAddHostView follow, so the focus list's controller wiring and the +// tvOS focus engine carry over untouched. +// +// Acknowledgements re-uses `Licenses.chunked` — the chunking that exists so tvOS can page a +// ~885 KB license wall by focus steps. Fed to `GamepadMenuList` as one row per chunk, the wall +// scrolls with the stick on every platform and needs no scrolling machinery of its own. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) || os(tvOS) + +struct GamepadAboutView: 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(\.openURL) private var openURL + /// How the host screen closes this one; nil falls back to the environment dismiss. + var close: (() -> Void)? + /// Whether this screen owns the controller — false while the shell is mid-transition. + var controllerActive = true + /// Gates the mic row of the shortcuts reference, exactly as the old banner gated it. + var micAvailable = true + @Environment(\.dismiss) private var dismiss + + /// Which layer the row list is showing. Depth is 1 by construction: neither sub-page opens + /// anything further, so there is no stack to model (the same reasoning as `GamepadScreen`). + private enum Layer: Equatable { + case main + case shortcuts + case licenses + } + + @State private var layer: Layer = .main + @State private var focusID: String? + + #if os(iOS) + /// `.compact` in a landscape phone window — tighter chrome so more rows fit. + @Environment(\.verticalSizeClass) private var vSizeClass + + private var compact: Bool { vSizeClass == .compact } + #else + private let compact = false + #endif + + 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")! + } + + private static let tagline = + "Low-latency desktop and game streaming with first-class Linux and Windows hosts." + + var body: some View { + GamepadMenuList( + items: rows, + focusID: $focusID, + onActivate: { activate(id: $0.id) }, + onBack: { back() }, + isActive: controllerActive + ) { row, focused in + rowView(row, focused: focused) + .frame(maxWidth: metrics.rowMaxWidth) + .padding(.horizontal, 24) + } + .frame(maxWidth: .infinity) + .safeAreaInset(edge: .top, spacing: 0) { + header + .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) { + GamepadHintBar(hints: hints) + // Equal distance from the left and bottom edges (see GamepadHomeView). + .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) } + } + // The launcher's field, calmed — same base as the settings and add-host screens. Hosted in + // the shell the field is the SHELL's (one persistent backdrop), so don't mount a second. + .background { + if !hostedInShell { GamepadFormBackground() } + } + .gamepadPaletteInk() + #if !os(tvOS) + // A gamepad UI exits with B; this keeps a hardware keyboard's Esc and the macOS sheet's + // cancel working without putting close chrome on screen. + .background { + Button("Cancel") { back() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + #endif + } + + // MARK: - Header + + /// The identity card on the main layer, a plain heading on the sub-pages. Laid out sideways + /// (icon leading, text trailing) rather than centred like the touch page: a console screen + /// spends its vertical budget on rows, and a centred stack of icon-name-version-tagline is + /// most of a 10-foot screen before the first row appears. + @ViewBuilder + private var header: some View { + switch layer { + case .main: + HStack(alignment: .center, spacing: compact ? 14 : 18) { + AppIconView(side: iconSide) + VStack(alignment: .leading, spacing: 2) { + Text("Punktfunk") + .font(.geist( + gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) + .foregroundStyle(ink.fg) + Text(Self.versionLine) + .font(.geist(metrics.detailFont, .medium, relativeTo: .caption)) + .monospacedDigit() + .foregroundStyle(ink.fg(0.7)) + if !compact { + Text(Self.tagline) + .font(.geist(metrics.detailFont, relativeTo: .caption)) + .foregroundStyle(ink.fg(0.5)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: metrics.rowMaxWidth * 0.66, alignment: .leading) + .padding(.top, 2) + } + } + Spacer(minLength: 0) + } + case .shortcuts, .licenses: + Text(title) + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) + .foregroundStyle(ink.fg) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var title: String { + switch layer { + case .main: return "About" + case .shortcuts: return "Shortcuts" + case .licenses: return "Acknowledgements" + } + } + + /// "Version 0.28.0 (1)" — the build number only when it says something the version doesn't. + /// 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))" + } + + private var iconSide: CGFloat { + #if os(tvOS) + return 108 + #else + return compact ? 46 : 62 + #endif + } + + // MARK: - Hints + + private var hints: [GamepadHint] { + let backText = layer == .main ? "Done" : "Back" + // The sub-pages are reading surfaces: nothing on them activates, so offering A would be + // the same lie a dimmed row tells. Only the main layer's rows do anything. + var hints: [GamepadHint] = [] + if layer == .main { + hints.append(.init( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Open", + action: { if let focusID { activate(id: focusID) } })) + } + hints.append(.init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: backText, + action: { back() })) + return hints + } + + /// B peels one layer: a sub-page back to the About rows — focus returning to the row it came + /// from — then the screen itself. + private func back() { + switch layer { + case .main: + performClose() + case .shortcuts: + layer = .main + focusID = "shortcuts" + case .licenses: + layer = .main + focusID = "licenses" + } + } + + private func performClose() { + if let close { + close() + } else { + dismiss() + } + } + + // MARK: - Rows + + /// One row. `detail` is nil on the reading surfaces, whose rows are prose rather than controls. + private struct Row: Identifiable { + let id: String + var icon: String? + let label: String + var value: String = "" + /// A section heading inside a reading surface (the shortcuts groups) — no glass, no glyph. + var isHeading = false + /// Monospaced, wrapping body text: a license chunk, or a shortcut's description. + var isProse = false + var activate: () -> Void = {} + } + + private var rows: [Row] { + switch layer { + case .main: return mainRows + case .shortcuts: return shortcutRows + case .licenses: return licenseRows + } + } + + private var mainRows: [Row] { + var list: [Row] = [ + Row( + id: "shortcuts", icon: "command", label: "Shortcuts", + value: "While streaming", + activate: { + focusID = shortcutRows.first?.id + layer = .shortcuts + }), + Row( + id: "licenses", icon: "text.document", label: "Acknowledgements", + value: "MIT or Apache-2.0", + activate: { + focusID = licenseRows.first?.id + layer = .licenses + }), + ] + // tvOS has no browser and no `openURL`, so an address there is text to read off the + // screen, not a link to nowhere — the same call the touch About page makes. + list.append(contentsOf: [ + linkRow(id: "docs", icon: "book", label: "Documentation", url: Destination.docs), + linkRow( + id: "community", icon: "bubble.left.and.bubble.right", label: "Community", + url: Destination.community), + linkRow( + id: "source", icon: "chevron.left.forwardslash.chevron.right", + label: "Source code", url: Destination.source), + ]) + return list + } + + private func linkRow(id: String, icon: String, label: String, url: URL) -> Row { + // Shown without the scheme: the host is what identifies the destination, and "https://" + // is 8 characters of every row that never varies. + let shown = url.absoluteString.replacingOccurrences(of: "https://", with: "") + #if os(tvOS) + return Row(id: id, icon: icon, label: label, value: shown) + #else + return Row(id: id, icon: icon, label: label, value: shown, activate: { openURL(url) }) + #endif + } + + /// The shortcuts reference: a heading per group, then one prose row per shortcut. Same catalog + /// the touch page renders, so the two never drift. + private var shortcutRows: [Row] { + ShortcutsCatalog.groups(micAvailable: micAvailable).flatMap { group -> [Row] in + [Row(id: "group-\(group.title)", label: group.title, isHeading: true)] + + group.items.map { item in + Row( + id: "sc-\(group.title)-\(item.keys)", label: item.keys, value: item.text, + isProse: true) + } + } + } + + /// The license wall, one row per pre-chunked page (see `Licenses.chunked`). The header block + /// the touch page draws in prose becomes the first two rows. + private var licenseRows: [Row] { + var list: [Row] = [ + Row(id: "lic-heading", label: "Punktfunk", isHeading: true), + Row( + 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.", + isProse: true), + ] + for (i, chunk) in Licenses.chunked(Licenses.appLicense).enumerated() { + list.append(Row(id: "lic-app-\(i)", label: chunk, isProse: true)) + } + list.append(Row(id: "lic-third-heading", label: "Third-party software", isHeading: true)) + for (i, chunk) in Licenses.thirdPartyNoticesChunks.enumerated() { + list.append(Row(id: "lic-third-\(i)", label: chunk, isProse: true)) + } + return list + } + + private func activate(id: String) { + rows.first { $0.id == id }?.activate() + } + + // MARK: - Row rendering + + @ViewBuilder + private func rowView(_ row: Row, focused: Bool) -> some View { + let m = metrics + if row.isHeading { + Text(row.label) + .font(.geist(m.labelFont, .bold, relativeTo: .headline)) + .foregroundStyle(ink.fg(0.75)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, m.rowHPad) + .padding(.top, 14) + .padding(.bottom, 2) + } else if row.isProse { + // A reading row: the focus treatment is a quiet wash rather than the control rows' + // full glass — focus here means "this is the part you are scrolled to", not "press A". + VStack(alignment: .leading, spacing: 4) { + Text(row.label) + .font(.geistFixed(m.valueFont, .medium)) + .foregroundStyle(ink.fg(0.95)) + .fixedSize(horizontal: false, vertical: true) + if !row.value.isEmpty { + Text(row.value) + .font(.geist(m.detailFont, relativeTo: .caption)) + .foregroundStyle(ink.fg(0.6)) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, m.rowHPad) + .padding(.vertical, m.rowVPad * 0.7) + .background { + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) + .fill(ink.fg(focused ? 0.08 : 0)) + } + .animation(.smooth(duration: 0.18), value: focused) + } else { + HStack(spacing: 14) { + if let icon = row.icon { + Image(systemName: icon) + .font(.system(size: m.iconFont)) + .foregroundStyle(focused ? ink.accent : ink.fg(0.55)) + .frame(width: m.iconWidth) + } + Text(row.label) + .font(.geist(m.labelFont, .semibold, relativeTo: .body)) + .foregroundStyle(ink.fg) + .lineLimit(1) + Spacer(minLength: 12) + Text(row.value) + .font(.geist(m.valueFont, .medium, relativeTo: .callout)) + .foregroundStyle(focused ? ink.fg(0.85) : ink.fg(0.55)) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(.horizontal, m.rowHPad) + .padding(.vertical, m.rowVPad) + .consoleGlass( + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), + tint: focused ? ink.accent(0.30) : nil, + interactive: focused) + .overlay { + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) + .strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1) + } + .scaleEffect(focused ? 1.0 : 0.98) + .animation(.smooth(duration: 0.18), value: focused) + } + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 81d280c1..5f405e62 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -64,6 +64,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,8 +138,24 @@ 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 + /// The About page (identity, licenses, and the shortcuts reference) is up over this screen. + @State private var showAbout = false var body: some View { + // About replaces this screen's content rather than pushing over it — one layer deeper, + // the same rule the pin picker follows, so B peels back here exactly as it does there. + // It carries its own header and legend, which is why it takes the whole frame. + if showAbout { + GamepadAboutView( + close: { showAbout = false }, + controllerActive: controllerActive, + micAvailable: micAvailable) + } else { + settingsBody + } + } + + private var settingsBody: some View { GamepadMenuList( items: rows, focusID: $focusID, @@ -329,6 +348,13 @@ struct GamepadSettingsView: View { if let close { close() } else { dismiss() } } + /// Just the marketing version for the About row's value — the build number that + /// `GamepadAboutView` also shows would not fit a settings row, and the page itself is one + /// press away for anyone who needs it. + private static var shortVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" + } + /// "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 { @@ -690,6 +716,15 @@ struct GamepadSettingsView: View { label: "Controller-optimized UI", detail: "Turn off to use the touch interface even with a controller connected.", value: $gamepadUIEnabled), + // Not a setting, so it adjusts to nothing (left/right is a boundary thud) and A opens + // it — the same shape the Profiles rows use to reach the pin picker. + Row( + id: "about", tab: .interface, icon: "info.circle", label: "About Punktfunk", + value: Self.shortVersion, + detail: "Version and licenses, and the shortcuts for streaming on this device.", + adjustable: false, + adjust: { _ in false }, + activate: { showAbout = true }), ] // WHEN the switch above takes over. Built only while it is on: with the switch off this // screen is unreachable in the first place (no gamepad UI to open it from), so a 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 +} -- 2.54.0 From e057bd60f496f4c6900ecd604acba054d83e3023 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 22:57:17 +0200 Subject: [PATCH 2/5] refactor(apple/gamepad-ui): About is its own section, not the last row of Interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reachable, but wrong: About sat at the bottom of the Interface tab, under the palette and the overlay position — a page about the app filed among the settings that change how it looks, found only by scrolling past them. It is a tab now, trailing the strip beside 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. The standalone GamepadAboutView goes away with it. Its content is the tab's rows, its two reading surfaces (shortcuts, licences) are in-place layers like the pin picker, and the identity card — icon, name, version, tagline — rides in the header under the tab strip. In the header rather than as a first row so the list holds no focus stop that does nothing when pressed; laid out sideways rather than centred like the touch page, because this header already carries a title and a strip and a centred icon-name-version-tagline stack would leave no room for the rows under it. Row grows a `kind`, so the About tab can draw a heading and a block of prose without every other tab's rows pretending to be one. swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0; 288 tests pass. --- .../Settings/GamepadAboutView.swift | 406 ------------------ .../Settings/GamepadSettingsView.swift | 303 +++++++++++-- 2 files changed, 269 insertions(+), 440 deletions(-) delete mode 100644 clients/apple/Sources/PunktfunkClient/Settings/GamepadAboutView.swift diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadAboutView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadAboutView.swift deleted file mode 100644 index e9ce3bfd..00000000 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadAboutView.swift +++ /dev/null @@ -1,406 +0,0 @@ -// The gamepad UI's About page — the controller-first counterpart of `AboutView`, reached from the -// settings screen's About tab. Same content in the same order as the touch page (icon, name, -// version, then the ways out: documentation, community, source, licenses), restyled as a console -// screen and navigable with a stick: the identity card is the header, everything else is a row. -// -// It also carries the SHORTCUTS reference, which is why this page exists now rather than whenever -// an About page happened to get written. The controls used to be announced by a 6-second banner at -// the start of every session (ContentView's `showShortcutHint`) — a message that interrupts you -// once, while you are looking at the stream you just opened, and is unavailable forever after. -// Moving the words here trades a notification for a destination: nothing flashes over the stream, -// and the answer is reachable at the moment the question is actually asked. -// -// Both sub-pages are IN-PLACE layers, not pushes — the same "one layer, B peels it back" rule the -// settings pin picker and GamepadAddHostView follow, so the focus list's controller wiring and the -// tvOS focus engine carry over untouched. -// -// Acknowledgements re-uses `Licenses.chunked` — the chunking that exists so tvOS can page a -// ~885 KB license wall by focus steps. Fed to `GamepadMenuList` as one row per chunk, the wall -// scrolls with the stick on every platform and needs no scrolling machinery of its own. - -import PunktfunkKit -import SwiftUI -#if os(iOS) || os(macOS) || os(tvOS) - -struct GamepadAboutView: 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(\.openURL) private var openURL - /// How the host screen closes this one; nil falls back to the environment dismiss. - var close: (() -> Void)? - /// Whether this screen owns the controller — false while the shell is mid-transition. - var controllerActive = true - /// Gates the mic row of the shortcuts reference, exactly as the old banner gated it. - var micAvailable = true - @Environment(\.dismiss) private var dismiss - - /// Which layer the row list is showing. Depth is 1 by construction: neither sub-page opens - /// anything further, so there is no stack to model (the same reasoning as `GamepadScreen`). - private enum Layer: Equatable { - case main - case shortcuts - case licenses - } - - @State private var layer: Layer = .main - @State private var focusID: String? - - #if os(iOS) - /// `.compact` in a landscape phone window — tighter chrome so more rows fit. - @Environment(\.verticalSizeClass) private var vSizeClass - - private var compact: Bool { vSizeClass == .compact } - #else - private let compact = false - #endif - - 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")! - } - - private static let tagline = - "Low-latency desktop and game streaming with first-class Linux and Windows hosts." - - var body: some View { - GamepadMenuList( - items: rows, - focusID: $focusID, - onActivate: { activate(id: $0.id) }, - onBack: { back() }, - isActive: controllerActive - ) { row, focused in - rowView(row, focused: focused) - .frame(maxWidth: metrics.rowMaxWidth) - .padding(.horizontal, 24) - } - .frame(maxWidth: .infinity) - .safeAreaInset(edge: .top, spacing: 0) { - header - .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) { - GamepadHintBar(hints: hints) - // Equal distance from the left and bottom edges (see GamepadHomeView). - .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) } - } - // The launcher's field, calmed — same base as the settings and add-host screens. Hosted in - // the shell the field is the SHELL's (one persistent backdrop), so don't mount a second. - .background { - if !hostedInShell { GamepadFormBackground() } - } - .gamepadPaletteInk() - #if !os(tvOS) - // A gamepad UI exits with B; this keeps a hardware keyboard's Esc and the macOS sheet's - // cancel working without putting close chrome on screen. - .background { - Button("Cancel") { back() } - .keyboardShortcut(.cancelAction) - .buttonStyle(.plain) - .frame(width: 0, height: 0) - .opacity(0) - .accessibilityHidden(true) - } - #endif - } - - // MARK: - Header - - /// The identity card on the main layer, a plain heading on the sub-pages. Laid out sideways - /// (icon leading, text trailing) rather than centred like the touch page: a console screen - /// spends its vertical budget on rows, and a centred stack of icon-name-version-tagline is - /// most of a 10-foot screen before the first row appears. - @ViewBuilder - private var header: some View { - switch layer { - case .main: - HStack(alignment: .center, spacing: compact ? 14 : 18) { - AppIconView(side: iconSide) - VStack(alignment: .leading, spacing: 2) { - Text("Punktfunk") - .font(.geist( - gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(ink.fg) - Text(Self.versionLine) - .font(.geist(metrics.detailFont, .medium, relativeTo: .caption)) - .monospacedDigit() - .foregroundStyle(ink.fg(0.7)) - if !compact { - Text(Self.tagline) - .font(.geist(metrics.detailFont, relativeTo: .caption)) - .foregroundStyle(ink.fg(0.5)) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: metrics.rowMaxWidth * 0.66, alignment: .leading) - .padding(.top, 2) - } - } - Spacer(minLength: 0) - } - case .shortcuts, .licenses: - Text(title) - .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(ink.fg) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - private var title: String { - switch layer { - case .main: return "About" - case .shortcuts: return "Shortcuts" - case .licenses: return "Acknowledgements" - } - } - - /// "Version 0.28.0 (1)" — the build number only when it says something the version doesn't. - /// 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))" - } - - private var iconSide: CGFloat { - #if os(tvOS) - return 108 - #else - return compact ? 46 : 62 - #endif - } - - // MARK: - Hints - - private var hints: [GamepadHint] { - let backText = layer == .main ? "Done" : "Back" - // The sub-pages are reading surfaces: nothing on them activates, so offering A would be - // the same lie a dimmed row tells. Only the main layer's rows do anything. - var hints: [GamepadHint] = [] - if layer == .main { - hints.append(.init( - glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Open", - action: { if let focusID { activate(id: focusID) } })) - } - hints.append(.init( - glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: backText, - action: { back() })) - return hints - } - - /// B peels one layer: a sub-page back to the About rows — focus returning to the row it came - /// from — then the screen itself. - private func back() { - switch layer { - case .main: - performClose() - case .shortcuts: - layer = .main - focusID = "shortcuts" - case .licenses: - layer = .main - focusID = "licenses" - } - } - - private func performClose() { - if let close { - close() - } else { - dismiss() - } - } - - // MARK: - Rows - - /// One row. `detail` is nil on the reading surfaces, whose rows are prose rather than controls. - private struct Row: Identifiable { - let id: String - var icon: String? - let label: String - var value: String = "" - /// A section heading inside a reading surface (the shortcuts groups) — no glass, no glyph. - var isHeading = false - /// Monospaced, wrapping body text: a license chunk, or a shortcut's description. - var isProse = false - var activate: () -> Void = {} - } - - private var rows: [Row] { - switch layer { - case .main: return mainRows - case .shortcuts: return shortcutRows - case .licenses: return licenseRows - } - } - - private var mainRows: [Row] { - var list: [Row] = [ - Row( - id: "shortcuts", icon: "command", label: "Shortcuts", - value: "While streaming", - activate: { - focusID = shortcutRows.first?.id - layer = .shortcuts - }), - Row( - id: "licenses", icon: "text.document", label: "Acknowledgements", - value: "MIT or Apache-2.0", - activate: { - focusID = licenseRows.first?.id - layer = .licenses - }), - ] - // tvOS has no browser and no `openURL`, so an address there is text to read off the - // screen, not a link to nowhere — the same call the touch About page makes. - list.append(contentsOf: [ - linkRow(id: "docs", icon: "book", label: "Documentation", url: Destination.docs), - linkRow( - id: "community", icon: "bubble.left.and.bubble.right", label: "Community", - url: Destination.community), - linkRow( - id: "source", icon: "chevron.left.forwardslash.chevron.right", - label: "Source code", url: Destination.source), - ]) - return list - } - - private func linkRow(id: String, icon: String, label: String, url: URL) -> Row { - // Shown without the scheme: the host is what identifies the destination, and "https://" - // is 8 characters of every row that never varies. - let shown = url.absoluteString.replacingOccurrences(of: "https://", with: "") - #if os(tvOS) - return Row(id: id, icon: icon, label: label, value: shown) - #else - return Row(id: id, icon: icon, label: label, value: shown, activate: { openURL(url) }) - #endif - } - - /// The shortcuts reference: a heading per group, then one prose row per shortcut. Same catalog - /// the touch page renders, so the two never drift. - private var shortcutRows: [Row] { - ShortcutsCatalog.groups(micAvailable: micAvailable).flatMap { group -> [Row] in - [Row(id: "group-\(group.title)", label: group.title, isHeading: true)] - + group.items.map { item in - Row( - id: "sc-\(group.title)-\(item.keys)", label: item.keys, value: item.text, - isProse: true) - } - } - } - - /// The license wall, one row per pre-chunked page (see `Licenses.chunked`). The header block - /// the touch page draws in prose becomes the first two rows. - private var licenseRows: [Row] { - var list: [Row] = [ - Row(id: "lic-heading", label: "Punktfunk", isHeading: true), - Row( - 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.", - isProse: true), - ] - for (i, chunk) in Licenses.chunked(Licenses.appLicense).enumerated() { - list.append(Row(id: "lic-app-\(i)", label: chunk, isProse: true)) - } - list.append(Row(id: "lic-third-heading", label: "Third-party software", isHeading: true)) - for (i, chunk) in Licenses.thirdPartyNoticesChunks.enumerated() { - list.append(Row(id: "lic-third-\(i)", label: chunk, isProse: true)) - } - return list - } - - private func activate(id: String) { - rows.first { $0.id == id }?.activate() - } - - // MARK: - Row rendering - - @ViewBuilder - private func rowView(_ row: Row, focused: Bool) -> some View { - let m = metrics - if row.isHeading { - Text(row.label) - .font(.geist(m.labelFont, .bold, relativeTo: .headline)) - .foregroundStyle(ink.fg(0.75)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, m.rowHPad) - .padding(.top, 14) - .padding(.bottom, 2) - } else if row.isProse { - // A reading row: the focus treatment is a quiet wash rather than the control rows' - // full glass — focus here means "this is the part you are scrolled to", not "press A". - VStack(alignment: .leading, spacing: 4) { - Text(row.label) - .font(.geistFixed(m.valueFont, .medium)) - .foregroundStyle(ink.fg(0.95)) - .fixedSize(horizontal: false, vertical: true) - if !row.value.isEmpty { - Text(row.value) - .font(.geist(m.detailFont, relativeTo: .caption)) - .foregroundStyle(ink.fg(0.6)) - .fixedSize(horizontal: false, vertical: true) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, m.rowHPad) - .padding(.vertical, m.rowVPad * 0.7) - .background { - RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) - .fill(ink.fg(focused ? 0.08 : 0)) - } - .animation(.smooth(duration: 0.18), value: focused) - } else { - HStack(spacing: 14) { - if let icon = row.icon { - Image(systemName: icon) - .font(.system(size: m.iconFont)) - .foregroundStyle(focused ? ink.accent : ink.fg(0.55)) - .frame(width: m.iconWidth) - } - Text(row.label) - .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(ink.fg) - .lineLimit(1) - Spacer(minLength: 12) - Text(row.value) - .font(.geist(m.valueFont, .medium, relativeTo: .callout)) - .foregroundStyle(focused ? ink.fg(0.85) : ink.fg(0.55)) - .lineLimit(1) - .truncationMode(.middle) - } - .padding(.horizontal, m.rowHPad) - .padding(.vertical, m.rowVPad) - .consoleGlass( - RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), - tint: focused ? ink.accent(0.30) : nil, - interactive: focused) - .overlay { - RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) - .strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1) - } - .scaleEffect(focused ? 1.0 : 0.98) - .animation(.smooth(duration: 0.18), value: focused) - } - } -} -#endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 5f405e62..5ba2bb79 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). @@ -138,24 +143,16 @@ 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 - /// The About page (identity, licenses, and the shortcuts reference) is up over this screen. - @State private var showAbout = false - - var body: some View { - // About replaces this screen's content rather than pushing over it — one layer deeper, - // the same rule the pin picker follows, so B peels back here exactly as it does there. - // It carries its own header and legend, which is why it takes the whole frame. - if showAbout { - GamepadAboutView( - close: { showAbout = false }, - controllerActive: controllerActive, - micAvailable: micAvailable) - } else { - settingsBody - } + /// 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 } - private var settingsBody: some View { + @State private var aboutPage: AboutPage? + + var body: some View { GamepadMenuList( items: rows, focusID: $focusID, @@ -179,9 +176,12 @@ 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 } + // The identity card belongs to the About section, so it appears with it and goes + // away on its reading pages (which carry their own title instead). + if pinTarget == nil, aboutPage == nil, tab == .about { aboutIdentity } } .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) @@ -348,23 +348,102 @@ struct GamepadSettingsView: View { if let close { close() } else { dismiss() } } - /// Just the marketing version for the About row's value — the build number that - /// `GamepadAboutView` also shows would not fit a settings row, and the page itself is one - /// press away for anyone who needs it. - private static var shortVersion: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" + /// 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")! + } + + private static let tagline = + "Low-latency desktop and game streaming with first-class Linux and Windows hosts." + + /// "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))" + } + + /// The identity card, shown under the tab strip while About is the section. Laid out sideways + /// (icon leading, text trailing) rather than centred like the touch page: this sits inside a + /// settings header that already carries a title and a tab strip, and a centred stack of + /// icon-name-version-tagline would leave no room for the rows underneath it. + private var aboutIdentity: some View { + HStack(alignment: .center, spacing: compact ? 12 : 16) { + AppIconView(side: aboutIconSide) + VStack(alignment: .leading, spacing: 1) { + Text("Punktfunk") + .font(.geist(metrics.labelFont, .bold, relativeTo: .headline)) + .foregroundStyle(ink.fg) + Text(Self.versionLine) + .font(.geist(metrics.detailFont, .medium, relativeTo: .caption)) + .monospacedDigit() + .foregroundStyle(ink.fg(0.7)) + if !compact { + Text(Self.tagline) + .font(.geist(metrics.detailFont, relativeTo: .caption)) + .foregroundStyle(ink.fg(0.5)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: metrics.rowMaxWidth * 0.62, alignment: .leading) + .padding(.top, 1) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 24) + } + + private var aboutIconSide: CGFloat { + #if os(tvOS) + return 84 + #else + return compact ? 38 : 52 + #endif } /// "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`). @@ -412,6 +491,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() } @@ -419,7 +501,45 @@ 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 .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. @@ -531,10 +651,21 @@ 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 + } } /// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows @@ -556,9 +687,122 @@ 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), + ]) + 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] { @@ -716,15 +960,6 @@ struct GamepadSettingsView: View { label: "Controller-optimized UI", detail: "Turn off to use the touch interface even with a controller connected.", value: $gamepadUIEnabled), - // Not a setting, so it adjusts to nothing (left/right is a boundary thud) and A opens - // it — the same shape the Profiles rows use to reach the pin picker. - Row( - id: "about", tab: .interface, icon: "info.circle", label: "About Punktfunk", - value: Self.shortVersion, - detail: "Version and licenses, and the shortcuts for streaming on this device.", - adjustable: false, - adjust: { _ in false }, - activate: { showAbout = true }), ] // WHEN the switch above takes over. Built only while it is on: with the switch off this // screen is unreachable in the first place (no gamepad UI to open it from), so a row -- 2.54.0 From ed8c080603cd8e3ace7b75ea7631173d75537743 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 23:04:29 +0200 Subject: [PATCH 3/5] fix(apple/about): the identity card ignored the row column, and the TV had no icon to show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found on glass on an Apple TV. The card was laid out against the SCREEN while everything under it is laid out against a centred column of `rowMaxWidth` — 920pt against a 1920-wide TV. So it began a few hundred points to the left of every row it introduced and read as a separate banner rather than the head of the list. It now takes the same column and the same inner inset as a row's contents, so the icon sits directly above the row icons. And it was drawing the "P" monogram, never the app's mark. That fallback exists because tvOS ships its icon as a parallax image STACK (Back/Circle1/Circle2/ Front) with no single image to load, so `AppIconView.bundleIcon` returned nil there and always had. `AboutAppIcon` is those four layers flattened into one asset, generated from the SAME art the stack uses so the two cannot drift into being subtly different icons. A TV icon is a 400x240 rectangle rather than a squircle, so `side` means HEIGHT on tvOS and the width follows the real 5:3 art — framed square it would have sat in a box two thirds empty. Verified the asset actually survives compilation (`assetutil` finds AboutAppIcon in the built Assets.car at both scales) — a missing imageset would silently fall back to the monogram again, which is exactly the bug being fixed. swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0. --- .../AboutAppIcon.imageset/Contents.json | 18 ++++++++++++++++++ .../AboutAppIcon.imageset/about-icon@1x.png | Bin 0 -> 32788 bytes .../AboutAppIcon.imageset/about-icon@2x.png | Bin 0 -> 89446 bytes .../PunktfunkClient/Settings/AboutView.swift | 16 +++++++++++++++- .../Settings/GamepadSettingsView.swift | 8 ++++++++ 5 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/Contents.json create mode 100644 clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/about-icon@1x.png create mode 100644 clients/apple/App/Assets.xcassets/AboutAppIcon.imageset/about-icon@2x.png 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 0000000000000000000000000000000000000000..9959cc993817ccdd805bfd79c0127a2a25bc83e6 GIT binary patch literal 32788 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 kYreDv9-KP)BwO>X2e+YAfGXMYp literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f56da675f521a2ea5b12c38a2ade68298c36c245 GIT binary patch literal 89446 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 XMieo|MD$s$0C?EhI$mtB_DcLWH{)F& literal 0 HcmV?d00001 diff --git a/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift b/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift index a022e31c..a72ba8f6 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift @@ -278,7 +278,14 @@ struct AppIconView: View { 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. + #if os(tvOS) + .frame(width: side * (400.0 / 240.0), height: side) + #else .frame(width: side, height: side) + #endif .accessibilityHidden(true) // the app's name is the next line } @@ -312,7 +319,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 5ba2bb79..419e99ef 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -395,6 +395,14 @@ struct GamepadSettingsView: View { } Spacer(minLength: 0) } + // Line up with the ROWS, not the screen. They are a centred column of `rowMaxWidth` + // (920 on a TV, against a 1920-wide screen), so a full-width identity card started a few + // hundred points to the left of every row under it and read as a separate banner. Same + // column, same inner inset as a row's contents, so the icon sits directly above the row + // icons — then centred in the header the way the list centres its rows. + .padding(.horizontal, metrics.rowHPad) + .frame(maxWidth: metrics.rowMaxWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 24) } -- 2.54.0 From 6fd5769b3b367eecad66c31d4141ad50e942b0bc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 23:10:34 +0200 Subject: [PATCH 4/5] fix(apple/about): a zero-radius clip is still a clip, and it cropped the TV's wide icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cornerRadius: 0` reads as "no rounding", but a RoundedRectangle clip is not a no-op at zero — it still clips to the layout frame, so any art whose aspect ratio isn't the frame's loses its ends. The TV's 400x240 icon did exactly that as soon as there was a real icon to draw instead of the square monogram. The mask now applies only where it is wanted: iOS, whose icon ships unmasked because the springboard rounds it at draw time. The frame goes from fixed to MAX for the same failure one step further out: at a fixed width the image cannot shrink when its row is tight, so it overflows and is cropped by whatever is above it. `.fit` inside a max frame gives the whole icon back, just smaller. And the icon takes layout priority in the identity card — the tagline beside it is happy to wrap, and a 5:3 icon is what suffers first if the text is given the width it asks for. swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0. --- .../PunktfunkClient/Settings/AboutView.swift | 35 ++++++++++++------- .../Settings/GamepadSettingsView.swift | 4 +++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift b/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift index a72ba8f6..2ef125c1 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift @@ -264,25 +264,36 @@ 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. + // 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(width: side * (400.0 / 240.0), height: side) + .frame(maxWidth: side * (400.0 / 240.0), maxHeight: side) #else .frame(width: side, height: side) #endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 419e99ef..2ad028f3 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -375,7 +375,11 @@ struct GamepadSettingsView: View { /// icon-name-version-tagline would leave no room for the rows underneath it. private var aboutIdentity: some View { HStack(alignment: .center, spacing: compact ? 12 : 16) { + // The icon gets the row first: the tagline beside it is happy to wrap or shorten, + // and a TV icon is 5:3, so it is the element that suffers first if the text takes + // the width it asks for. AppIconView(side: aboutIconSide) + .layoutPriority(1) VStack(alignment: .leading, spacing: 1) { Text("Punktfunk") .font(.geist(metrics.labelFont, .bold, relativeTo: .headline)) -- 2.54.0 From 9c2c8d1643ef3b37ccd1b82e4a4121381aee6551 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 23:28:04 +0200 Subject: [PATCH 5/5] fix(apple/about): drop the identity card for a version line under the rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card led the About tab with the app icon, and on tvOS that icon is a 400x240 rectangle meeting a layout built for square art. Three passes at framing it — aspect-correct frame, then dropping the zero-radius clip that was cropping it, then a max frame so it could shrink instead of overflow — and it was still cut off on real hardware. So the card goes. A version string answers the only question anyone opens About to ask, it has no aspect ratio to get wrong, and it belongs under the rows rather than over them: quiet and centred, reading as a footer instead of a row you failed to press. `Row.Kind.footer` draws it. swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0; on glass on an Apple TV as 0.29.0 (100004). --- .../Settings/GamepadSettingsView.swift | 76 +++++-------------- 1 file changed, 21 insertions(+), 55 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 2ad028f3..562d56e0 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -179,9 +179,6 @@ struct GamepadSettingsView: View { // 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 } - // The identity card belongs to the About section, so it appears with it and goes - // away on its reading pages (which carry their own title instead). - if pinTarget == nil, aboutPage == nil, tab == .about { aboutIdentity } } .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) @@ -356,9 +353,6 @@ struct GamepadSettingsView: View { static let source = URL(string: "https://git.unom.io/unom/punktfunk")! } - private static let tagline = - "Low-latency desktop and game streaming with first-class Linux and Windows hosts." - /// "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 { @@ -369,55 +363,6 @@ struct GamepadSettingsView: View { return "Version \(short) (\(build))" } - /// The identity card, shown under the tab strip while About is the section. Laid out sideways - /// (icon leading, text trailing) rather than centred like the touch page: this sits inside a - /// settings header that already carries a title and a tab strip, and a centred stack of - /// icon-name-version-tagline would leave no room for the rows underneath it. - private var aboutIdentity: some View { - HStack(alignment: .center, spacing: compact ? 12 : 16) { - // The icon gets the row first: the tagline beside it is happy to wrap or shorten, - // and a TV icon is 5:3, so it is the element that suffers first if the text takes - // the width it asks for. - AppIconView(side: aboutIconSide) - .layoutPriority(1) - VStack(alignment: .leading, spacing: 1) { - Text("Punktfunk") - .font(.geist(metrics.labelFont, .bold, relativeTo: .headline)) - .foregroundStyle(ink.fg) - Text(Self.versionLine) - .font(.geist(metrics.detailFont, .medium, relativeTo: .caption)) - .monospacedDigit() - .foregroundStyle(ink.fg(0.7)) - if !compact { - Text(Self.tagline) - .font(.geist(metrics.detailFont, relativeTo: .caption)) - .foregroundStyle(ink.fg(0.5)) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: metrics.rowMaxWidth * 0.62, alignment: .leading) - .padding(.top, 1) - } - } - Spacer(minLength: 0) - } - // Line up with the ROWS, not the screen. They are a centred column of `rowMaxWidth` - // (920 on a TV, against a 1920-wide screen), so a full-width identity card started a few - // hundred points to the left of every row under it and read as a separate banner. Same - // column, same inner inset as a row's contents, so the icon sits directly above the row - // icons — then centred in the header the way the list centres its rows. - .padding(.horizontal, metrics.rowHPad) - .frame(maxWidth: metrics.rowMaxWidth, alignment: .leading) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.horizontal, 24) - } - - private var aboutIconSide: CGFloat { - #if os(tvOS) - return 84 - #else - return compact ? 38 : 52 - #endif - } - /// "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 { @@ -517,6 +462,14 @@ struct GamepadSettingsView: View { 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)) @@ -677,6 +630,8 @@ struct GamepadSettingsView: View { case control case heading case prose + /// Quiet, centred trailing text — the About tab's version line. + case footer } } @@ -735,6 +690,17 @@ struct GamepadSettingsView: View { 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 } -- 2.54.0