From 3ba19f28a23dd1b3d69230eb260f3e041459211a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 10 Jul 2026 16:58:35 +0200 Subject: [PATCH] feat(apple): the gamepad UI comes to tvOS - focus-driven, with real session controls The console UI now runs on tvOS through the NATIVE focus engine: carousel cards and settings rows are focusable Buttons (Siri Remote and pads both navigate; imperative scrollTo replaces the drop-prone scrollPosition binding), while iOS/macOS keep the 60 Hz poll untouched - on tvOS it carries only what focus has no concept of: X/Y screen actions and left/right value adjust with the poll's dominant-axis feel (onMoveCommand proved input-source-dependent: keyboard intercepted, pad dpad not -> double steps). Text entry uses the system fullscreen keyboard (TVTextEntry); pairing + library present as covers under the launcher; the game library defaults ON; settings values slide a quiet 14 pt in the step's direction. Session controls: controller/remote input routes EXCLUSIVELY through GameController during a stream (GCEventViewController, interaction disabled) - a pad's B no longer doubles as a UIKit menu press that ended sessions mid-game. Deliberate exits only: the cross-client escape chord (hold L1+R1+Start+Select 1.5 s - pf-client-core's contract, now implemented on all Apple platforms) and holding the remote's Back >= 1 s; the start-of-stream banner (now also on tvOS) teaches both. The Siri Remote's touch surface drives the host pointer - press = left click, Play/Pause = right click, release-tail jumps gated so motion stays truly relative. tvOS 26 regressions fixed at the root: the app-wide brand tint rendered every unfocused control as a blank pill (tint dropped on tvOS) and the 17 pt root font shrank the whole platform (29 pt there), plus 10-foot sizing across host cards, the gamepad screens, and the stats HUD (whose misleading "Press Menu" hint is gone). Acknowledgements scrolls by focus-sized chunks and Menu pops instead of suspending; full-width focusSections make the home actions reachable from any column. The presenter defaults to stage-3 glass pacing on tvOS (a 60 Hz panel fed a 60 fps stream is the sticky-FIFO worst case behind the 50 ms display stage) and is pickable from the gamepad settings; HDR capability advertises from AVPlayer.eligibleForHDRPlayback instead of the current mode's EDR headroom, so an SDR home screen no longer hides an HDR TV. Co-Authored-By: Claude Fable 5 --- .../apple/Punktfunk.xcodeproj/project.pbxproj | 12 +- .../Sources/PunktfunkClient/ContentView.swift | 77 +++++--- .../Home/GamepadAddHostView.swift | 108 +++++++++-- .../Home/GamepadCarousel.swift | 121 +++++++++--- .../PunktfunkClient/Home/GamepadChrome.swift | 103 ++++++++++- .../Home/GamepadHomeView.swift | 84 +++++++-- .../Home/GamepadMenuList.swift | 78 +++++++- .../PunktfunkClient/Home/HomeView.swift | 28 ++- .../PunktfunkClient/Home/HostCards.swift | 5 +- .../Home/LibraryCoverflowView.swift | 4 +- .../PunktfunkClient/Home/LibraryView.swift | 10 +- .../PunktfunkClient/PunktfunkClientApp.swift | 12 ++ .../Session/SessionModel.swift | 59 +++++- .../Session/StreamHUDView.swift | 39 ++-- .../Settings/AcknowledgementsView.swift | 76 ++++++-- .../Settings/GamepadSettingsView.swift | 84 ++++++--- .../Settings/SettingsOptions.swift | 35 +++- .../Settings/SettingsView.swift | 36 ++-- .../PunktfunkClient/Support/GlassStyle.swift | 20 +- .../PunktfunkClient/Trust/PairSheet.swift | 4 +- .../PunktfunkKit/Audio/SessionAudio.swift | 11 +- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 37 ++++ .../Gamepad/GamepadFeedback.swift | 12 +- .../Input/SiriRemotePointer.swift | 173 ++++++++++++++++++ .../PunktfunkKit/Support/Licenses.swift | 34 +++- .../PunktfunkKit/Video/SessionPresenter.swift | 33 +++- .../PunktfunkKit/Video/Stage2Pipeline.swift | 38 +++- .../PunktfunkKit/Video/StreamPump.swift | 15 +- .../PunktfunkKit/Views/StreamViewIOS.swift | 102 ++++++++++- 29 files changed, 1208 insertions(+), 242 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkKit/Input/SiriRemotePointer.swift diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 36770c6e..3e2c88c2 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -376,7 +376,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = macosx; @@ -412,7 +412,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = macosx; @@ -449,7 +449,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -490,7 +490,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -522,7 +522,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; @@ -552,7 +552,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 422e2766..1b0c8c23 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -68,25 +68,28 @@ struct ContentView: View { /// edge-to-edge (behind the notch); windowed respects the top inset so the title bar /// 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. + /// 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(macOS) @State private var showSettings = false #endif - #if os(iOS) || os(macOS) // A connected controller (+ the Settings toggle) swaps the whole home screen for // GamepadHomeView instead of retrofitting HomeView's touch/desktop UI — see `home` below. + // On tvOS the same screens are focus-engine-driven, so the Siri Remote keeps working; + // with no (extended) controller attached tvOS falls back to HomeView as before. @ObservedObject private var gamepadManager = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true private var gamepadUIActive: Bool { GamepadUIEnvironment.isActive( gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) } - #endif var body: some View { Group { @@ -108,7 +111,7 @@ struct ContentView: View { .onChange(of: model.phase) { _, phase in switch phase { case .streaming: - #if os(macOS) + #if os(macOS) || os(tvOS) showShortcutHint = true // the 6 s shortcut banner, per session start #endif // A session actually started — remember it on the card ("Connected … ago" @@ -278,13 +281,30 @@ struct ContentView: View { onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) } } - #elseif os(iOS) + #else Group { if gamepadUIActive { GamepadHomeView( store: store, model: model, discovery: discovery, libraryTarget: $libraryTarget, waker: waker, connect: { connect($0) }, connectDiscovered: connectDiscovered) + // On tvOS pairing/library normally present from HomeView's navigationDestinations + // — which aren't mounted while the gamepad launcher is up. Give the launcher its + // own presenters (exactly one of the two homes is mounted at a time, so these can + // never double-present against HomeView's routes). Menu closes a cover the same + // way B backs out elsewhere; PairSheet's own onDisappear cancels a live ceremony. + #if os(tvOS) + .fullScreenCover(item: $pairingTarget) { host in + PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) } + .onExitCommand { pairingTarget = nil } + } + .fullScreenCover(item: $libraryTarget) { host in + NavigationStack { + LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) }) + } + .onExitCommand { libraryTarget = nil } + } + #endif } else { HomeView( store: store, model: model, discovery: discovery, @@ -295,14 +315,6 @@ struct ContentView: View { onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) } } - #else - HomeView( - store: store, model: model, discovery: discovery, - showAddHost: $showAddHost, pairingTarget: $pairingTarget, - speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, - showSettings: $showSettings, - connect: { connect($0) }, connectDiscovered: connectDiscovered, - onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) #endif } @@ -362,11 +374,14 @@ struct ContentView: View { #else .background(Color.black) .ignoresSafeArea() - // Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable - // disconnect control during play, the controller's buttons flow to the host instead of - // driving the focus engine. NOTE: a game controller's Menu is also forwarded to the - // host as Start — the Siri Remote is the intended disconnect path. - .onExitCommand { model.disconnect() } + // SWALLOW Menu/B during a session — a game controller's B button ALSO surfaces as this + // UIKit menu press, so the old instant-disconnect here ended the session on every B + // press in gameplay. The button still reaches the host via GamepadCapture; the + // DELIBERATE exits are holding the remote's Back ≥ 1 s (SiriRemotePointer) and holding + // L1+R1+Start+Select ≥ 1.5 s on a pad (GamepadCapture's escape chord), both surfaced by + // the start-of-stream banner. The empty handler is what keeps the press from bubbling + // out and suspending the app. + .onExitCommand {} #endif } @@ -418,17 +433,18 @@ struct ContentView: View { } .animation(.smooth(duration: 0.28), value: statsVerbosity) } - #if os(macOS) - // The start-of-stream shortcut banner (Windows-client parity): the full - // reserved key set on a glass pill, bottom-centre, for the first 6 seconds of + #if os(macOS) || os(tvOS) + // The start-of-stream shortcut banner (Windows-client parity): the platform's + // reserved controls on a glass pill, bottom-centre, 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). + // 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. .overlay(alignment: .bottom) { if captureEnabled && showShortcutHint { - Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · " - + "⌃⌥⇧D disconnects · ⌃⌥⇧S stats") - .font(.geist(12, relativeTo: .caption)) + Text(Self.shortcutHintText) + .font(.geist(Self.shortcutHintFont, relativeTo: .caption)) .foregroundStyle(.secondary) .padding(.horizontal, 14) .padding(.vertical, 8) @@ -472,6 +488,17 @@ struct ContentView: View { } } + #if os(macOS) + private static let shortcutHintText = + "Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats" + private static let shortcutHintFont: CGFloat = 12 + #elseif os(tvOS) + private static let shortcutHintText = + "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" + private static let shortcutHintFont: CGFloat = 22 // read from the couch + #endif + // MARK: - Connect private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index dd8c6868..30a820c5 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -1,13 +1,15 @@ -// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS) — the controller counterpart of +// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS/tvOS) — the controller counterpart of // AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address / // port) plus the Add action, navigated with the same vertical focus list as the gamepad settings; // A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end // without touching the screen. Field edits are live (the row shows every keystroke); B closes the // keyboard first, then cancels the screen — the same "back peels one layer" rule as a console UI. +// tvOS swaps the custom keyboard tray for the SYSTEM fullscreen keyboard (TVTextEntry): unlike +// iOS/macOS, tvOS HAS a first-class controller/remote-drivable text entry, so the native one wins. import PunktfunkKit import SwiftUI -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(tvOS) struct GamepadAddHostView: View { @Environment(\.dismiss) private var dismiss @@ -37,22 +39,22 @@ struct GamepadAddHostView: View { isActive: editing == nil ) { row, focused in rowView(row, focused: focused) - .frame(maxWidth: 620) + .frame(maxWidth: GamepadFormMetrics.rowMaxWidth) .padding(.horizontal, 24) } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { VStack(spacing: 4) { Text("Add Host") - .font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(.white) if !compact { Text("Hosts on this network appear automatically — add one by address " + "for everything else.") - .font(.geist(13, relativeTo: .caption)) + .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) .foregroundStyle(.white.opacity(0.55)) .multilineTextAlignment(.center) - .frame(maxWidth: 440) + .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72) } } .padding(.top, gamepadTitleTopPadding(compact: compact)) @@ -75,10 +77,38 @@ struct GamepadAddHostView: View { .onChange(of: port) { _, value in if value.count > 5 { port = String(value.prefix(5)) } } + #if os(tvOS) + // tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom + // tray — the remote and the pad both drive it natively. Same `editing` state as the + // tray, just a different presentation; done (or Menu, edits-stick) commits and returns. + .fullScreenCover(isPresented: Binding( + get: { editing != nil }, + set: { if !$0 { editing = nil } }) + ) { + if let field = editing { + TVTextEntry( + title: fieldTitle(field), + text: editingBinding(field).wrappedValue, + keyboardType: keyboardType(field) + ) { value in + commitEntry(field, value) + editing = nil + } + } + } + #endif } - /// The keyboard tray while editing, the controls legend otherwise. + /// The keyboard tray while editing, the controls legend otherwise. (tvOS never shows the + /// tray — `editing` presents the system keyboard cover instead — so it's legend-only there.) @ViewBuilder private var bottomTray: some View { + #if os(tvOS) + GamepadHintBar(hints: [ + .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"), + .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"), + ]) + .frame(maxWidth: .infinity, alignment: .leading) + #else if let editing { VStack(spacing: 10) { GamepadKeyboard( @@ -104,6 +134,7 @@ struct GamepadAddHostView: View { ]) .frame(maxWidth: .infinity, alignment: .leading) } + #endif } /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc @@ -111,14 +142,16 @@ struct GamepadAddHostView: View { private var closeButton: some View { Button { dismiss() } label: { Image(systemName: "xmark") - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) .foregroundStyle(.white) - .frame(width: 34, height: 34) + .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) .glassBackground(Circle(), interactive: true) .contentShape(Circle()) } .buttonStyle(.plain) - .keyboardShortcut(.cancelAction) + #if !os(tvOS) + .keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there) + #endif .accessibilityLabel("Cancel") } @@ -142,19 +175,20 @@ struct GamepadAddHostView: View { } private func rowView(_ row: Row, focused: Bool) -> some View { - HStack(spacing: 14) { + let m = GamepadFormMetrics.self + return HStack(spacing: 14) { if row.isAction { Label("Add Host", systemImage: "plus.circle.fill") - .font(.geist(16, .semibold, relativeTo: .body)) + .font(.geist(m.labelFont, .semibold, relativeTo: .body)) .foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35)) .frame(maxWidth: .infinity) } else { Text(row.label) - .font(.geist(16, .semibold, relativeTo: .body)) + .font(.geist(m.labelFont, .semibold, relativeTo: .body)) .foregroundStyle(.white) Spacer(minLength: 12) Text(row.value.isEmpty ? row.placeholder : row.value) - .font(.geistFixed(15, .medium)) + .font(.geistFixed(m.valueFont, .medium)) .foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white) .lineLimit(1) .truncationMode(.head) // keep the end of a long address visible while typing @@ -162,20 +196,20 @@ struct GamepadAddHostView: View { // The live-edit caret: this row is what the keyboard tray is typing into. Rectangle() .fill(Color.brand) - .frame(width: 2, height: 18) + .frame(width: 2, height: m.labelFont + 2) } } } - .padding(.horizontal, 16) - .padding(.vertical, 13) + .padding(.horizontal, m.rowHPad) + .padding(.vertical, m.rowVPad) // Liquid Glass rows, matching the settings screen; the focused (or actively edited) row // takes the brand wash, and the edited row keeps its brand caret border. .consoleGlass( - RoundedRectangle(cornerRadius: 14, style: .continuous), + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil, interactive: focused) .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) .strokeBorder( editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) @@ -235,5 +269,41 @@ struct GamepadAddHostView: View { default: return nil } } + + #if os(tvOS) + // MARK: - System keyboard plumbing (see the fullScreenCover on `body`) + + private func fieldTitle(_ id: String) -> String { + switch id { + case "name": return "Name (optional)" + case "port": return "Port" + default: return "Address (IP or hostname)" + } + } + + /// .URL for the address (dots on the primary layer, no autocapitalize) — the closest tvOS + /// keyboard to "hostname or IP". + private func keyboardType(_ id: String) -> UIKeyboardType { + switch id { + case "port": return .numberPad + case "address": return .URL + default: return .default + } + } + + /// Apply a system-keyboard result, enforcing what `allowedCharacters` enforces per keystroke + /// on the other platforms (the system keyboard will type anything). + private func commitEntry(_ id: String, _ value: String) { + switch id { + case "port": + editingBinding(id).wrappedValue = String(value.filter(\.isNumber).prefix(5)) + case "address": + editingBinding(id).wrappedValue = value + .replacingOccurrences(of: " ", with: "") + default: + editingBinding(id).wrappedValue = value + } + } + #endif } #endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 5e44900e..64369f6d 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -1,6 +1,11 @@ // The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the // library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely -// by a controller (iOS/iPadOS/macOS). +// by a controller (iOS/iPadOS/macOS) — or, on tvOS, by the NATIVE FOCUS ENGINE: every card is a +// focusable Button, so the Siri Remote and a game controller both navigate through the system +// (dpad/swipe moves focus, select activates, Menu backs out at the presentation level), and the +// cursor/scroll chase the focused card instead of the poll. The poll still runs on tvOS but +// carries ONLY the Y/X actions (library/settings) — buttons the focus engine has no concept of. +// The iOS/macOS poll-driven behavior is untouched by the tvOS mode. // // The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)` // snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live @@ -24,7 +29,7 @@ import PunktfunkKit import SwiftUI -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(tvOS) struct GamepadCarousel: View where Item.ID: Hashable { let items: [Item] @@ -54,6 +59,11 @@ struct GamepadCarousel: View where Item.ID: Hash @State private var input = GamepadMenuInput(manager: .shared) @State private var haptics = MenuHaptics(manager: .shared) + #if os(tvOS) + /// tvOS: the focus engine is the navigation authority — `cursor`/`scrolledID` chase this, + /// never the other way around (mirroring the poll's cursor-first discipline). + @FocusState private var focusedID: Item.ID? + #endif /// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back /// while the gamepad is driving — that's the whole desync fix. @State private var cursor = 0 @@ -81,26 +91,72 @@ struct GamepadCarousel: View where Item.ID: Hash var body: some View { GeometryReader { geo in let inset = max(0, (geo.size.width - itemWidth) / 2) - ScrollView(.horizontal) { - HStack(spacing: spacing) { - ForEach(items) { item in - card(item) - .frame(width: itemWidth) - .contentShape(Rectangle()) - .onTapGesture { tap(item) } + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: spacing) { + ForEach(items) { item in + #if os(tvOS) + // A focusable Button per card: the focus engine does the navigating + // (remote swipes and pad dpad alike), select activates. The bare style + // below keeps the tile's own look — the `.scrollTransition` center pop + // is the focus treatment, since focus and center track each other. + Button { activate(item) } label: { + card(item) + .frame(width: itemWidth) + } + .buttonStyle(ConsoleBareButtonStyle()) + .focused($focusedID, equals: item.id) + .id(item.id) + #else + card(item) + .frame(width: itemWidth) + .contentShape(Rectangle()) + .onTapGesture { tap(item) } + #endif + } + } + .frame(height: geo.size.height) // fill so shorter cards center vertically + .scrollTargetLayout() + } + // The two-way `.scrollPosition` + snap machinery serves the POLL/touch platforms. + // Not on tvOS: that binding DROPS a write landing mid-animation (the very desync + // the poll's cursor design exists to avoid — see the header), and on tvOS the + // focus engine's own reveal-scrolls are always in flight, so drops were routine + // ("navigation not reflected in the scroll view"). tvOS scrolls imperatively + // below instead — scrollTo RE-TARGETS mid-animation (the GamepadMenuList pattern). + #if !os(tvOS) + .scrollPosition(id: $scrolledID) + .scrollTargetBehavior(.viewAligned) + #endif + // .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden + // and paints a scroller across the console strip. + .scrollIndicators(.never) + .scrollClipDisabled() // let the focused card scale up past the strip bounds + .safeAreaPadding(.horizontal, inset) + .offset(x: bumpOffset) + #if os(tvOS) + // Land initial focus on the first card (the launcher's first host / the coverflow's + // first title) instead of wherever the engine guesses. + .defaultFocus($focusedID, items.first?.id) + // Focus moved (remote swipe / pad dpad) — chase it: cursor, detail selection, + // controller detent, and an imperative center scroll. + .onChange(of: focusedID) { _, newValue in + guard let idx = index(of: newValue), idx != cursor else { return } + cursor = idx + lastNav = Date() + haptics.move() + selection = newValue + withAnimation(.easeOut(duration: scrollAnim)) { + proxy.scrollTo(newValue, anchor: .center) } } - .frame(height: geo.size.height) // fill so shorter cards center vertically - .scrollTargetLayout() + // The list changed under a stable focus (discovered hosts prepend tiles): the + // content shifted but no focus change fires above — re-center the focused card. + .onChange(of: items.map(\.id)) { _, _ in + if let id = focusedID { proxy.scrollTo(id, anchor: .center) } + } + #endif } - .scrollPosition(id: $scrolledID) - .scrollTargetBehavior(.viewAligned) - // .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden - // and paints a scroller across the console strip. - .scrollIndicators(.never) - .scrollClipDisabled() // let the focused card scale up past the strip bounds - .safeAreaPadding(.horizontal, inset) - .offset(x: bumpOffset) } .sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick) @@ -128,13 +184,16 @@ struct GamepadCarousel: View where Item.ID: Hash // A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a // programmatic scroll is animating (its own intermediate id write-backs would regress the // cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag - // — which never sets `isScrolling` — moves the cursor here. + // — which never sets `isScrolling` — moves the cursor here. Not on tvOS: there is no touch + // drag, and the focus engine's own reveal-scrolls must never steal the cursor from focus. + #if !os(tvOS) .onChange(of: scrolledID) { _, newValue in guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return } guard let idx = index(of: newValue), idx != cursor else { return } cursor = idx selection = newValue } + #endif // Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the // current `items` value (a plain array — unlike an observed object it would otherwise go // stale in the closures stored on `input`). @@ -147,12 +206,20 @@ struct GamepadCarousel: View where Item.ID: Hash // MARK: - Input wiring private func wire() { + #if os(tvOS) + // The focus engine owns move/confirm/back on tvOS (that's what keeps the Siri Remote + // working on this screen — and what routes Menu through the system's back semantics). + // The poll carries only the buttons focus has no concept of: Y/X, the screen actions. + input.onSecondary = onSecondary + input.onTertiary = onTertiary + #else input.onMove = { move($0) } input.onConfirm = { activate() } input.onSecondary = onSecondary input.onTertiary = onTertiary input.onBack = onBack input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil + #endif } private func move(_ direction: GamepadMenuInput.Direction) { @@ -212,9 +279,14 @@ struct GamepadCarousel: View where Item.ID: Hash private func activate() { guard cursor >= 0, cursor < items.count else { return } + activate(items[cursor]) + } + + /// Shared confirm tail — the poll activates the cursor's item, a tvOS Button its own. + private func activate(_ item: Item) { activateTick &+= 1 haptics.confirm() - onActivate(items[cursor]) + onActivate(item) } /// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping @@ -257,6 +329,13 @@ struct GamepadCarousel: View where Item.ID: Hash scrolledID = id selection = id } + #if os(tvOS) + // Keep real focus on the reconciled item when its old target vanished from the list — + // the engine would otherwise pick a neighbour by geometry and drag the cursor with it. + if focusedID == nil || index(of: focusedID) == nil, cursor < items.count { + focusedID = items[cursor].id + } + #endif } private func boundaryBump(forward: Bool) { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index ae0745f0..be9c6940 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -2,11 +2,12 @@ // GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the // controller-glyph hint bar, and the connected-controller status chip. One look across every // screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages. -// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead. +// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS — where the same screens are driven by +// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList). import PunktfunkKit import SwiftUI -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(tvOS) import GameController /// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via @@ -31,6 +32,51 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat { #endif } +/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the +/// in-hand compact-aware sizes elsewhere. +func gamepadTitleSize(compact: Bool) -> CGFloat { + #if os(tvOS) + 44 + #else + compact ? 20 : 30 + #endif +} + +/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView, +/// GamepadAddHostView) — one set of numbers so the two screens read as the same surface, +/// sized for the couch on tvOS and for the hand elsewhere. +enum GamepadFormMetrics { + #if os(tvOS) + static let headerFont: CGFloat = 17 + static let labelFont: CGFloat = 23 + static let valueFont: CGFloat = 21 + static let iconFont: CGFloat = 24 + static let iconWidth: CGFloat = 40 + static let chevronFont: CGFloat = 16 + static let rowHPad: CGFloat = 24 + static let rowVPad: CGFloat = 19 + static let rowCorner: CGFloat = 18 + static let rowMaxWidth: CGFloat = 920 + static let detailFont: CGFloat = 19 + static let closeFont: CGFloat = 20 + static let closeSide: CGFloat = 48 + #else + static let headerFont: CGFloat = 12 + static let labelFont: CGFloat = 16 + static let valueFont: CGFloat = 15 + static let iconFont: CGFloat = 17 + static let iconWidth: CGFloat = 28 + static let chevronFont: CGFloat = 12 + static let rowHPad: CGFloat = 16 + static let rowVPad: CGFloat = 13 + static let rowCorner: CGFloat = 14 + static let rowMaxWidth: CGFloat = 620 + static let detailFont: CGFloat = 13 + static let closeFont: CGFloat = 14 + static let closeSide: CGFloat = 34 + #endif +} + /// One glyph + label cell in a hint bar. struct GamepadHint: Identifiable { let glyph: String @@ -45,21 +91,32 @@ struct GamepadHint: Identifiable { struct GamepadHintBar: View { let hints: [GamepadHint] + // 10-foot legend on tvOS, in-hand sizes elsewhere. + #if os(tvOS) + private static let glyphFont: CGFloat = 27 + private static let textFont: CGFloat = 20 + private static let pad: CGFloat = 18 + #else + private static let glyphFont: CGFloat = 19 + private static let textFont: CGFloat = 14 + private static let pad: CGFloat = 13 + #endif + var body: some View { HStack(spacing: 18) { ForEach(hints) { hint in HStack(spacing: 7) { Image(systemName: hint.glyph) - .font(.system(size: 19)) + .font(.system(size: Self.glyphFont)) .foregroundStyle(.white) Text(hint.text) } .fixedSize() // keep glyph + label together; never truncate a hint mid-word } } - .font(.geist(14, .semibold, relativeTo: .subheadline)) + .font(.geist(Self.textFont, .semibold, relativeTo: .subheadline)) .foregroundStyle(.white.opacity(0.85)) - .padding(13) + .padding(Self.pad) .consoleGlass(Capsule()) .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) } @@ -297,30 +354,56 @@ struct GamepadFormBackground: View { } } +#if os(tvOS) +/// Bare chrome for the focusable console Buttons (carousel cards, menu-list rows) on tvOS: the +/// tile/row draws its own look and the screen's own focus treatment marks the focused element +/// (the carousel's `.scrollTransition` center pop, the list row's `focused` styling), so the +/// system's lift/halo would double up on it. Press feedback is a small dip, matching the +/// interactive-glass feel elsewhere. +struct ConsoleBareButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.smooth(duration: 0.15), value: configuration.isPressed) + } +} +#endif + /// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet /// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders /// when the pad or its battery state changes. struct ControllerStatusChip: View { let controller: GamepadManager.DiscoveredController + // Legible from the couch on tvOS, quiet in hand elsewhere. + #if os(tvOS) + private static let font: CGFloat = 17 + private static let hPad: CGFloat = 16 + private static let vPad: CGFloat = 10 + #else + private static let font: CGFloat = 12 + private static let hPad: CGFloat = 12 + private static let vPad: CGFloat = 7 + #endif + var body: some View { HStack(spacing: 7) { Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill") - .font(.system(size: 12)) + .font(.system(size: Self.font)) Text(controller.name) .lineLimit(1) if let level = controller.batteryLevel { Image(systemName: batterySymbol(level)) - .font(.system(size: 12)) + .font(.system(size: Self.font)) .foregroundStyle(level <= 0.2 && !controller.isCharging ? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7))) } } - .font(.geist(12, .medium, relativeTo: .caption)) + .font(.geist(Self.font, .medium, relativeTo: .caption)) .foregroundStyle(.white.opacity(0.7)) - .padding(.horizontal, 12) - .padding(.vertical, 7) + .padding(.horizontal, Self.hPad) + .padding(.vertical, Self.vPad) .background(Capsule().fill(.white.opacity(0.08))) .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 6ac2e086..ef44fd44 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -1,4 +1,4 @@ -// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host +// The gamepad-driven home screen: a distinct, "10-foot" console-style host // launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built // around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is // required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the @@ -14,11 +14,13 @@ // `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's // vertical budget — and the card is sized off the remaining height. macOS mounts it too (the // couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets -// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead). +// (macOS has no fullScreenCover). tvOS mounts it as well, driven by the native focus engine +// (see GamepadCarousel's tvOS mode) so the Siri Remote works alongside the pad; Play/Pause +// mirrors X for Settings since the focus engine has no concept of that button. import PunktfunkKit import SwiftUI -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(tvOS) import GameController /// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host @@ -60,8 +62,9 @@ struct GamepadHomeView: View { let connect: (StoredHost) -> Void let connectDiscovered: (DiscoveredHost) -> Void - /// Same experimental gate the touch grid's "Browse Library…" context-menu item uses. - @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false + /// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the + /// Settings "Game library" toggle opts out). + @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true #if os(iOS) /// `.compact` in a landscape phone window — drives tighter chrome so everything still fits. @Environment(\.verticalSizeClass) private var vSizeClass @@ -104,6 +107,12 @@ struct GamepadHomeView: View { try? await Task.sleep(for: .seconds(10)) } } + // The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces + // X, and historically tvOS maps a pad's X to this same press — the poll and this command + // double-firing just sets the same Bool twice. + #if os(tvOS) + .onPlayPauseCommand { showSettings = true } + #endif // The settings / add-host screens take over the controller (the carousel's `isActive` // gate above). iOS presents them full screen — the immersive console feel; macOS has no // fullScreenCover, so they become generously sized sheets over the dimmed launcher. @@ -128,11 +137,17 @@ struct GamepadHomeView: View { // MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints @ViewBuilder private func hero(for size: CGSize) -> some View { + #if os(tvOS) + // 10-foot scale: the phone-sized card reads like a postage stamp from the couch. + let cardWidth = min(560, size.width * 0.34) + let cardHeight = min(350, max(240, size.height - 260)) + #else let cardWidth = min(340, size.width * 0.84) // 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip // always fits the region the pinned title / hints safe-area insets leave. (The old detail // line below the strip is gone — it only re-printed what the centered card already shows.) let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48)) + #endif VStack(spacing: compact ? 8 : 10) { Spacer(minLength: 0) carousel(cardWidth: cardWidth, cardHeight: cardHeight) @@ -145,7 +160,7 @@ struct GamepadHomeView: View { private var titleBar: some View { Text("Select a Host") - .font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .overlay(alignment: .trailing) { @@ -158,6 +173,14 @@ struct GamepadHomeView: View { } } + private var cardSpacing: CGFloat { + #if os(tvOS) + 44 + #else + 30 + #endif + } + // MARK: - Carousel private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View { @@ -165,7 +188,7 @@ struct GamepadHomeView: View { items: tiles, selection: $selection, itemWidth: cardWidth, - spacing: 30, + spacing: cardSpacing, onActivate: { $0.activate() }, onSecondary: { openLibraryForSelected() }, onTertiary: { showSettings = true }, @@ -272,6 +295,31 @@ private struct GamepadHostTile: View { let tile: HomeTile let size: CGSize + // 10-foot metrics on tvOS, in-hand metrics elsewhere — one tile, two viewing distances. + #if os(tvOS) + private static let titleFont: CGFloat = 33 + private static let subtitleFont: CGFloat = 19 + private static let statusFont: CGFloat = 15 + private static let pipSide: CGFloat = 12 + private static let badgeSide: CGFloat = 70 + private static let badgeCorner: CGFloat = 19 + private static let monogramFont: CGFloat = 34 + private static let iconFont: CGFloat = 32 + private static let pad: CGFloat = 28 + private static let corner: CGFloat = 30 + #else + private static let titleFont: CGFloat = 23 + private static let subtitleFont: CGFloat = 13 + private static let statusFont: CGFloat = 11 + private static let pipSide: CGFloat = 9 + private static let badgeSide: CGFloat = 52 + private static let badgeCorner: CGFloat = 15 + private static let monogramFont: CGFloat = 25 + private static let iconFont: CGFloat = 24 + private static let pad: CGFloat = 20 + private static let corner: CGFloat = 26 + #endif + var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top, spacing: 8) { @@ -282,38 +330,38 @@ private struct GamepadHostTile: View { HStack(spacing: 7) { if tile.isPaired { Image(systemName: "lock.fill") - .font(.system(size: 11, weight: .semibold)) + .font(.system(size: Self.statusFont, weight: .semibold)) .foregroundStyle(.white.opacity(0.5)) } if tile.isOnline { Circle() .fill(Color.green) - .frame(width: 9, height: 9) + .frame(width: Self.pipSide, height: Self.pipSide) .shadow(color: .green.opacity(0.7), radius: 5) } } } Spacer(minLength: 0) Text(tile.title) - .font(.geist(23, .bold, relativeTo: .title2)) + .font(.geist(Self.titleFont, .bold, relativeTo: .title2)) .foregroundStyle(.white) .lineLimit(1) .minimumScaleFactor(0.7) Text(tile.subtitle) - .font(.geist(13, relativeTo: .caption)) + .font(.geist(Self.subtitleFont, relativeTo: .caption)) .foregroundStyle(.white.opacity(0.55)) .lineLimit(1) .padding(.top, 2) } - .padding(20) + .padding(Self.pad) .frame(width: size.width, height: size.height, alignment: .leading) // Liquid Glass console tile — a brand wash marks a saved host as primary; discovered / // Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself. .consoleGlass( - RoundedRectangle(cornerRadius: 26, style: .continuous), + RoundedRectangle(cornerRadius: Self.corner, style: .continuous), tint: tile.filled ? Color.brand.opacity(0.20) : nil) .overlay { - RoundedRectangle(cornerRadius: 26, style: .continuous) + RoundedRectangle(cornerRadius: Self.corner, style: .continuous) .strokeBorder( LinearGradient( colors: [.white.opacity(0.22), .white.opacity(0.04)], @@ -324,7 +372,7 @@ private struct GamepadHostTile: View { } private var monogramBadge: some View { - let shape = RoundedRectangle(cornerRadius: 15, style: .continuous) + let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous) return ZStack { shape.fill(tile.filled ? AnyShapeStyle(LinearGradient( @@ -335,15 +383,15 @@ private struct GamepadHostTile: View { ProgressView().tint(.white) } else if let icon = tile.icon { Image(systemName: icon) - .font(.system(size: 24, weight: .semibold)) + .font(.system(size: Self.iconFont, weight: .semibold)) .foregroundStyle(Color.brand) } else { Text(monogram(tile.title)) - .font(.geistFixed(25, .bold)) + .font(.geistFixed(Self.monogramFont, .bold)) .foregroundStyle(tile.filled ? .white : Color.brand) } } - .frame(width: 52, height: 52) + .frame(width: Self.badgeSide, height: Self.badgeSide) .overlay { if !tile.filled { shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift index f007196b..bf916fc4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift @@ -1,8 +1,14 @@ -// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for -// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a -// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs -// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus -// cursor, controller polling, haptics, and keeping the focused row scrolled into view. +// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list +// for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves +// a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B +// backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the +// focus cursor, controller polling, haptics, and keeping the focused row scrolled into view. +// +// On tvOS the rows are focusable Buttons and the NATIVE FOCUS ENGINE replaces the poll entirely +// (Siri Remote and pads both drive it: up/down moves focus, select activates, Menu — via +// onExitCommand — backs out). Left/right value-adjust isn't wired there; select cycles a value +// forward exactly like A does elsewhere, the standard tvOS settings interaction. The iOS/macOS +// poll-driven behavior is untouched by the tvOS mode. // // Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the // cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a @@ -16,7 +22,7 @@ import PunktfunkKit import SwiftUI -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(tvOS) struct GamepadMenuList: View where Item.ID: Hashable { let items: [Item] @@ -36,6 +42,15 @@ struct GamepadMenuList: View where Item.ID: Hasha @State private var input = GamepadMenuInput(manager: .shared) @State private var haptics = MenuHaptics(manager: .shared) + #if os(tvOS) + /// tvOS: the focus engine is the navigation authority for UP/DOWN — `cursor` chases this, so + /// the caller's `focused` row styling always matches real system focus. LEFT/RIGHT adjust + /// comes from the POLL (see `wire`), never from `.onMoveCommand`: the command stream is + /// 4-way with no axis data (diagonal scroll wobble buckets into left/right), and its + /// interception of up/down proved INPUT-SOURCE-DEPENDENT on hardware — keyboard arrows were + /// intercepted but a pad's dpad was not, so programmatic stepping double-moved every press. + @FocusState private var focusedID: Item.ID? + #endif /// Authoritative focus cursor (index into `items`). @State private var cursor = 0 /// A short vertical recoil when a move is refused at a list end. @@ -51,10 +66,23 @@ struct GamepadMenuList: View where Item.ID: Hasha ScrollView(.vertical) { LazyVStack(spacing: 6) { ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in + #if os(tvOS) + // A focusable Button per row: the engine moves between them, select + // activates (`tap` keeps the cursor in step before firing). The row's + // own `focused` styling is the focus treatment — the bare style adds + // no system chrome on top of it. + Button { tap(idx) } label: { + row(item, focusedID == item.id) + } + .buttonStyle(ConsoleBareButtonStyle()) + .focused($focusedID, equals: item.id) + .id(item.id) + #else row(item, idx == cursor && isActive) .contentShape(Rectangle()) .onTapGesture { tap(idx) } .id(item.id) + #endif } } .padding(.vertical, 10) @@ -69,6 +97,20 @@ struct GamepadMenuList: View where Item.ID: Hasha } } } + #if os(tvOS) + // Focus moved (remote swipe / pad dpad) — keep the cursor, the caller's focusID mirror, + // and the controller detent in step. Menu = the list's back action (both tvOS callers + // pass one; the screen behind would otherwise catch the press and peel too far). + .onChange(of: focusedID) { _, newValue in + guard let id = newValue, let idx = items.firstIndex(where: { $0.id == id }), + idx != cursor else { return } + cursor = idx + focusID = id + haptics.move() + } + .defaultFocus($focusedID, items.first?.id) + .onExitCommand { onBack?() } + #endif .sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.selection, trigger: adjustTick) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick) @@ -102,6 +144,22 @@ struct GamepadMenuList: View where Item.ID: Hasha // MARK: - Input wiring private func wire() { + #if os(tvOS) + // The focus engine owns up/down and select (Button rows) and Menu (onExitCommand) — the + // poll carries ONLY the horizontal axis, where its dominant-axis deadzone + hold-repeat + // are exactly the adjust feel the other platforms have, and where the focus engine has + // nothing to move to in a vertical list. Vertical poll directions are deliberately + // dropped: acting on them would double the engine's own focus moves. (The Siri Remote + // never reaches this poll — no extended profile — so remote users cycle values with + // select instead, which `activate` already does.) + input.onMove = { direction in + switch direction { + case .left: adjust(by: -1) + case .right: adjust(by: 1) + case .up, .down: break + } + } + #else input.onMove = { direction in switch direction { case .up: step(by: -1) @@ -112,6 +170,7 @@ struct GamepadMenuList: View where Item.ID: Hasha } input.onConfirm = { activate() } input.onBack = onBack + #endif } private func step(by delta: Int) { @@ -123,6 +182,7 @@ struct GamepadMenuList: View where Item.ID: Hasha haptics.move() } + private func adjust(by delta: Int) { guard let onAdjust, cursor >= 0, cursor < items.count else { return } if onAdjust(items[cursor], delta) { @@ -165,6 +225,12 @@ struct GamepadMenuList: View where Item.ID: Hasha cursor = min(max(cursor, 0), items.count - 1) focusID = items[cursor].id } + #if os(tvOS) + // Keep real focus on the reconciled row when its old target vanished from the list. + if focusedID == nil || !items.contains(where: { $0.id == focusedID }), cursor < items.count { + focusedID = items[cursor].id + } + #endif } private func boundaryBump(forward: Bool) { diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index f08e8c52..5dd3d3c9 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -29,8 +29,9 @@ struct HomeView: View { /// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online /// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker. let wake: (StoredHost) -> Void - /// Experimental game-library browser (gated) — the host-card "Browse Library…" action. - @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false + /// Game-library browser (default ON; the Settings toggle opts out) — the host-card + /// "Browse Library…" action. + @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true /// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet. @State private var editTarget: StoredHost? @@ -48,6 +49,13 @@ struct HomeView: View { } } .padding() + // Mirror of the action row's focusSection below: an UPWARD move from + // the centered buttons must land back in the grid even when no card + // sits in the buttons' columns (a lone top-left card, say). The grid + // spans the row, so the section catches every upward ray. + #if os(tvOS) + .focusSection() + #endif } if !discoveredUnsaved.isEmpty { discoveredSection @@ -67,6 +75,14 @@ struct HomeView: View { } } .padding(.top, 24) + // One FULL-WIDTH focus target for any downward move out of the grid. + // focusSection alone is not enough: the engine tests the section's + // FRAME, and a content-hugging centered HStack only overlaps the middle + // columns — a swipe down from an outer card dead-ends and the actions + // are unreachable by remote. Stretching the section across the row means + // every column's downward ray hits it. + .frame(maxWidth: .infinity) + .focusSection() #endif } } @@ -198,6 +214,10 @@ struct HomeView: View { } .padding([.horizontal, .bottom]) .padding(.top, store.hosts.isEmpty ? 0 : 8) + // Same reachability contract as the saved grid above — see its focusSection comment. + #if os(tvOS) + .focusSection() + #endif } /// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad @@ -259,7 +279,9 @@ struct HomeView: View { #if os(macOS) [GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)] #elseif os(tvOS) - [GridItem(.adaptive(minimum: 320), spacing: 48)] + // Tracks CardMetrics' 10-foot sizes — at the 30pt name a 320pt column truncates + // every hostname longer than ~10 characters. + [GridItem(.adaptive(minimum: 460), spacing: 48)] #else [GridItem(.adaptive(minimum: 280), spacing: 16)] #endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift index 1ac55742..1484455d 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift @@ -22,8 +22,9 @@ private struct CardMetrics { CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11, padding: 16, spacing: 14, radius: 12) #elseif os(tvOS) - CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14, - padding: 18, spacing: 18, radius: 14) + // 10-foot sizes — the 24pt-name tier read like a phone card from the couch. + CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17, + padding: 24, spacing: 22, radius: 18) #else CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5, padding: 13, spacing: 12, radius: 10) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 53c0266f..0c7248fd 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -1,4 +1,4 @@ -// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS — see LibraryView's +// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS/tvOS — see LibraryView's // `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the // scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card // (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and @@ -15,7 +15,7 @@ import PunktfunkKit import SwiftUI -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(tvOS) import GameController struct LibraryCoverflowView: View { diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 4ff70cf0..8945e2d1 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -21,9 +21,9 @@ struct LibraryView: View { /// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`; /// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session. @State private var imageSession: URLSession? - #if os(iOS) || os(macOS) - // Gamepad-driven browsing (iOS/iPadOS/macOS) — see ContentView's identical gate. tvOS keeps - // its existing plain-grid presentation of this same view unchanged. + #if os(iOS) || os(macOS) || os(tvOS) + // Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the + // setting off) every platform keeps the plain-grid presentation of this same view. @ObservedObject private var gamepadManager = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true private var gamepadUIActive: Bool { @@ -69,7 +69,6 @@ struct LibraryView: View { } else if games.isEmpty { emptyState } else { - #if os(iOS) || os(macOS) if gamepadUIActive { LibraryCoverflowView( games: games, imageSession: imageSession, onLaunch: onLaunch, @@ -77,9 +76,6 @@ struct LibraryView: View { } else { grid } - #else - grid - #endif } } diff --git a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift index 002af27a..84479aa3 100644 --- a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift +++ b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift @@ -38,10 +38,22 @@ struct PunktfunkClientApp: App { ContentView() #endif } + // NOT on tvOS: under the tvOS 26 glass button style a tinted UNFOCUSED control fills + // AND labels itself in the tint — every plain Button/TextField renders as a blank + // brand-violet pill until focused. Untinted, tvOS keeps the system glass look + // (visible labels, white focus lift); brand color stays on explicit Color.brand uses. + #if !os(tvOS) .tint(.brand) + #endif // Geist Sans is the app's typeface. This sets the default for unstyled text and the // form row labels; views that pick an explicit size/weight use `.geist(…)` directly. + // tvOS reads from across the room: its system body is 29pt, so pinning the phone's + // 17pt there shrank every unstyled control (rows, fields, buttons) to postage size. + #if os(tvOS) + .font(.geist(29, relativeTo: .body)) + #else .font(.geist(17, relativeTo: .body)) + #endif } // The Stream menu (Release Mouse ⌃⌥⇧Q, Disconnect ⌃⌥⇧D, Show/Hide Statistics ⌃⌥⇧S — // the cross-client Ctrl+Alt+Shift set) — a real menu bar on macOS, hardware-keyboard diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index c2b55dfb..8f6b9a3c 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -2,6 +2,7 @@ // handshake phase, and the pump-thread → main-actor stats relay. import Foundation +import os import PunktfunkKit import SwiftUI @@ -10,6 +11,15 @@ import SwiftUI #elseif canImport(UIKit) import UIKit #endif +#if os(tvOS) + import AVFoundation // AVPlayer.eligibleForHDRPlayback — the TV-capability HDR gate +#endif + +/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the +/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral +/// instrument: any visible overlay forces the metal layer through the compositor, which costs a +/// refresh period on the vsync-latched platforms — this is how to measure with it off. +private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats") /// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published /// values. NSLock instead of an actor — the writer is the (non-async) pump thread. @@ -119,6 +129,12 @@ final class SessionModel: ObservableObject { private var audio: SessionAudio? private var gamepadCapture: GamepadCapture? private var gamepadFeedback: GamepadFeedback? + #if os(tvOS) + /// Siri Remote → host pointer while streaming (touch surface moves, press = left click, + /// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See + /// SiriRemotePointer — same trust gate/lifecycle as the gamepad capture above. + private var remotePointer: SiriRemotePointer? + #endif var isBusy: Bool { phase != .idle } @@ -163,6 +179,14 @@ final class SessionModel: ObservableObject { let displayHDR: Bool = { #if os(macOS) return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0 + #elseif os(tvOS) + // NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and + // Apple's recommended setup runs an SDR home screen with Match Content — an + // HDR-capable TV would read 1.0 at connect time and never be advertised. The + // session switches the display to HDR10 itself once streaming (AVDisplayManager — + // see StreamViewIOS), so gate on the TV's mode-independent capability; if the + // switch never lands, the presenter's in-shader tone-map keeps PQ safe anyway. + return AVPlayer.eligibleForHDRPlayback #else return UIScreen.main.potentialEDRHeadroom > 1.0 #endif @@ -300,6 +324,10 @@ final class SessionModel: ObservableObject { // connection is still up); the feedback drain joins off-main like audio. gamepadCapture?.stop() gamepadCapture = nil + #if os(tvOS) + remotePointer?.stop() // releases any held click while the connection is still up + remotePointer = nil + #endif let feedback = gamepadFeedback gamepadFeedback = nil if let conn = connection { @@ -363,11 +391,20 @@ final class SessionModel: ObservableObject { // session's virtual pad is a DualSense). Same trust gate as audio — nothing is // forwarded during the trust prompt. let capture = GamepadCapture(connection: conn, manager: .shared) + // The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only + // controller way out of a stream (B/Menu is swallowed during sessions; see ContentView). + capture.onDisconnectRequest = { [weak self] in self?.disconnect() } capture.start() gamepadCapture = capture let feedback = GamepadFeedback(connection: conn, manager: .shared) feedback.start() gamepadFeedback = feedback + #if os(tvOS) + let pointer = SiriRemotePointer(connection: conn) + pointer.onDisconnectRequest = { [weak self] in self?.disconnect() } + pointer.start() + remotePointer = pointer + #endif } private func startStatsTimer() { @@ -429,12 +466,32 @@ final class SessionModel: ObservableObject { } else { self.decodeValid = false } - if let d = self.displayStage.drain() { + let displayWindow = self.displayStage.drain() + if let d = displayWindow { self.displayP50Ms = d.p50Ms self.displayValid = true } else { self.displayValid = false } + // Mirror the window to the unified log (see statsLog) — one line per second, + // stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs; + // `presents` counts frames that reached glass (the display meter's sample count) + // — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is + // upstream (host capture/encode or the network). + if frames > 0 { + let line = String( + format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f " + + "decode_p50=%.1f display_p50=%.1f lost=%d", + frames, + displayWindow?.count ?? 0, + self.endToEndValid ? self.endToEndP50Ms : -1, + self.endToEndValid ? self.endToEndP95Ms : -1, + self.hostNetworkValid ? self.hostNetworkP50Ms : -1, + self.decodeValid ? self.decodeP50Ms : -1, + self.displayValid ? self.displayP50Ms : -1, + lost) + statsLog.info("\(line, privacy: .public)") + } } } // .common so the HUD keeps updating during window drags / menu tracking. diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 5b55b771..72489a6c 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -26,7 +26,7 @@ struct StreamHUDView: View { // this card — its frame (and, on iOS, its clamped corner) animate to the new size — rather // than cross-fading a whole new card in. Only the inner content switches per tier. tierContent - .padding(10) + .padding(cardPadding) .glassBackground(cardShape) .padding(edgeInset) } @@ -145,36 +145,43 @@ struct StreamHUDView: View { .foregroundStyle(.secondary) } #endif - #if os(tvOS) - // No focusable control during play: a focusable button steals the controller's - // A press (the focus engine consumes it before the host sees it). Disconnect is - // the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it. - Text("Press Menu to disconnect") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - #else // ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden) // and in InputCapture's monitor while captured; this button is the in-overlay, - // click-to-disconnect affordance. + // click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a + // focusable control would steal the controller's A press from the host) NOR a hint + // line: the exits are the hold gestures the start-of-stream banner teaches (hold + // the remote's Back; hold L1+R1+Start+Select on a pad). #if os(macOS) Button("Disconnect (⌃⌥⇧D)") { model.disconnect() } .font(.geist(12, relativeTo: .caption)) - #else + #elseif os(iOS) Button("Disconnect") { model.disconnect() } .font(.geist(12, relativeTo: .caption)) #endif - #endif } } // MARK: - Card metrics - /// The OUTER gap between the card and the screen edge. (Inner content padding stays a fixed 10.) - /// On iOS the card hugs a physically rounded display corner, so it sits a little further in and - /// pairs with a concentric corner radius (below); on macOS/tvOS windows the classic 10 reads fine. + /// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the + /// couch (relative system styles), so the card's chrome must keep pace or it reads cramped. + private var cardPadding: CGFloat { + #if os(tvOS) + return 16 + #else + return 10 + #endif + } + + /// The OUTER gap between the card and the screen edge. On iOS the card hugs a physically + /// rounded display corner, so it sits a little further in and pairs with a concentric corner + /// radius (below); tvOS floats it well clear of the TV's overscan-ish edge; macOS windows + /// keep the classic 10. private var edgeInset: CGFloat { #if os(iOS) return 14 + #elseif os(tvOS) + return 24 #else return 10 #endif @@ -187,6 +194,8 @@ struct StreamHUDView: View { private var cardCornerRadius: CGFloat { #if os(iOS) return max(12, DeviceMetrics.displayCornerRadius - edgeInset) + #elseif os(tvOS) + return 16 // scales with the roomier padding #else return 10 #endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/AcknowledgementsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/AcknowledgementsView.swift index 02b89afe..5108c6b8 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/AcknowledgementsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/AcknowledgementsView.swift @@ -1,13 +1,25 @@ import PunktfunkKit import SwiftUI -/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the +/// Open-source acknowledgements: Punktfunk's own license (MIT OR Apache-2.0) followed by the /// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS. struct AcknowledgementsView: View { private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } + // TV-legible sizes for the explicitly-sized text; the in-hand sizes elsewhere. (The license + // walls use relative system styles, which already scale per platform.) + #if os(tvOS) + private static let titleFont: CGFloat = 36 + private static let headlineFont: CGFloat = 26 + private static let captionFont: CGFloat = 20 + #else + private static let titleFont: CGFloat = 22 + private static let headlineFont: CGFloat = 17 + private static let captionFont: CGFloat = 12 + #endif + var body: some View { ScrollView { // Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks, @@ -16,42 +28,40 @@ struct AcknowledgementsView: View { // notice chunks visually continuous; the header block carries its own spacing + bottom pad. LazyVStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 18) { - Text("punktfunk") - .font(.geist(22, .bold, relativeTo: .title2)) + Text("Punktfunk") + .font(.geist(Self.titleFont, .bold, relativeTo: .title2)) if let version { Text("Version \(version)") - .font(.geist(12, relativeTo: .caption)) + .font(.geist(Self.captionFont, relativeTo: .caption)) .foregroundStyle(.secondary) } - Text(Licenses.appLicense) + LicenseWall(text: Licenses.appLicense) .font(.caption.monospaced()) - .modifier(SelectableText()) Divider() Text("Bundled font") - .font(.geist(17, .semibold, relativeTo: .headline)) - Text("punktfunk ships the Geist typeface (Geist Sans), " + .font(.geist(Self.headlineFont, .semibold, relativeTo: .headline)) + Text("Punktfunk ships the Geist typeface (Geist Sans), " + "© The Geist Project Authors / Vercel, used under the SIL Open Font " + "License 1.1.") - .font(.geist(12, relativeTo: .caption)) + .font(.geist(Self.captionFont, relativeTo: .caption)) .foregroundStyle(.secondary) if !Licenses.fontLicense.isEmpty { - Text(Licenses.fontLicense) + LicenseWall(text: Licenses.fontLicense) .font(.caption2.monospaced()) - .modifier(SelectableText()) } Divider() Text("Third-party software") - .font(.geist(17, .semibold, relativeTo: .headline)) + .font(.geist(Self.headlineFont, .semibold, relativeTo: .headline)) Text( - "punktfunk uses the open-source components below, each under its own license. " + "Punktfunk uses the open-source components below, each under its own license. " + "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ " + "(dynamically linked, replaceable)." ) - .font(.geist(12, relativeTo: .caption)) + .font(.geist(Self.captionFont, relativeTo: .caption)) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -62,6 +72,7 @@ struct AcknowledgementsView: View { .font(.caption2.monospaced()) .frame(maxWidth: .infinity, alignment: .leading) .modifier(SelectableText()) + .modifier(TVFocusable()) } } .frame(maxWidth: 900, alignment: .leading) @@ -85,3 +96,40 @@ private struct SelectableText: ViewModifier { #endif } } + +/// Focus IS scrolling on tvOS: with nothing focusable in this pushed screen the license wall +/// couldn't move at all, and a Menu press had nothing inside the NavigationStack to route +/// through — it suspended the whole app instead of popping. Plain (non-interactive) focusability +/// on every license/notice chunk fixes both; a chunk is sized to about two thirds of a screen +/// (see Licenses.chunked), so each focus step reads as a page turn. The chunks must be SMALL +/// focus stops all the way down — one tall focusable block would strand focus at its top and the +/// next stop could sit past the LazyVStack's instantiation window. +private struct TVFocusable: ViewModifier { + func body(content: Content) -> some View { + #if os(tvOS) + content.focusable() + #else + content + #endif + } +} + +/// One license wall: a single selectable Text on touch/desktop; on tvOS, focus-page-sized +/// chunks (see TVFocusable). The caller's `.font` cascades into either form. +private struct LicenseWall: View { + let text: String + + var body: some View { + #if os(tvOS) + let chunks = Licenses.chunked(text) + ForEach(chunks.indices, id: \.self) { i in + Text(chunks[i]) + .frame(maxWidth: .infinity, alignment: .leading) + .modifier(TVFocusable()) + } + #else + Text(text) + .modifier(SelectableText()) + #endif + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 30fc6066..6e723de7 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -1,4 +1,4 @@ -// The gamepad-driven settings screen (iOS/iPadOS/macOS): the couch-relevant subset of SettingsView, +// The gamepad-driven settings screen (iOS/iPadOS/macOS/tvOS): the couch-relevant subset of SettingsView, // restyled as a console settings page and fully navigable with a controller — up/down moves the // focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the // gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom @@ -13,7 +13,7 @@ import PunktfunkKit import SwiftUI -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(tvOS) import GameController struct GamepadSettingsView: View { @@ -34,8 +34,9 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw = StatsVerbosity.current.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue - @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false + @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + @AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault @ObservedObject private var gamepads = GamepadManager.shared #if os(iOS) @@ -47,6 +48,9 @@ struct GamepadSettingsView: View { private let compact = false // no size classes on macOS; the sheet is sized generously #endif @State private var focusID: String? + /// 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 var body: some View { GamepadMenuList( @@ -57,13 +61,13 @@ struct GamepadSettingsView: View { onBack: { dismiss() } ) { row, focused in rowView(row, focused: focused) - .frame(maxWidth: 620) + .frame(maxWidth: GamepadFormMetrics.rowMaxWidth) .padding(.horizontal, 24) } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { Text("Settings") - .font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(.white) .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, compact ? 4 : 8) @@ -74,7 +78,7 @@ struct GamepadSettingsView: View { .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { Text(focusedDetail) - .font(.geist(13, relativeTo: .caption)) + .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) .foregroundStyle(.white.opacity(0.55)) .lineLimit(2, reservesSpace: true) .animation(.smooth(duration: 0.2), value: focusID) @@ -107,61 +111,78 @@ struct GamepadSettingsView: View { private var closeButton: some View { Button { dismiss() } label: { Image(systemName: "xmark") - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) .foregroundStyle(.white) - .frame(width: 34, height: 34) + .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) .glassBackground(Circle(), interactive: true) .contentShape(Circle()) } .buttonStyle(.plain) - .keyboardShortcut(.cancelAction) + #if !os(tvOS) + .keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there) + #endif .accessibilityLabel("Close settings") } // MARK: - Row rendering private func rowView(_ row: Row, focused: Bool) -> some View { - VStack(alignment: .leading, spacing: 6) { + let m = GamepadFormMetrics.self + return VStack(alignment: .leading, spacing: 6) { if let header = row.header { Text(header) - .font(.geist(12, .semibold, relativeTo: .caption)) + .font(.geist(m.headerFont, .semibold, relativeTo: .caption)) .tracking(1.4) .foregroundStyle(.white.opacity(0.45)) - .padding(.leading, 16) + .padding(.leading, m.rowHPad) .padding(.top, 14) } HStack(spacing: 14) { Image(systemName: row.icon) - .font(.system(size: 17)) + .font(.system(size: m.iconFont)) .foregroundStyle(focused ? Color.brand : .white.opacity(0.55)) - .frame(width: 28) + .frame(width: m.iconWidth) Text(row.label) - .font(.geist(16, .semibold, relativeTo: .body)) + .font(.geist(m.labelFont, .semibold, relativeTo: .body)) .foregroundStyle(.white) .lineLimit(1) Spacer(minLength: 12) HStack(spacing: 9) { Image(systemName: "chevron.left") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle(.white.opacity(focused ? 0.6 : 0)) - Text(row.value) - .font(.geist(15, .medium, relativeTo: .callout)) - .foregroundStyle(focused ? .white : .white.opacity(0.6)) - .lineLimit(1) + // Keyed by the value so a change slides the new option in instead of + // hard-swapping the string — a QUIET horizontal slip following the user's + // motion (a right-step enters from the right), crossfading over ~14 pt. + // Deliberately not `.push`: that travels the whole container width, loud + // and visibly outside the row. The ZStack is the stable home the + // removed/inserted texts transition within. + let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14 + ZStack { + Text(row.value) + .font(.geist(m.valueFont, .medium, relativeTo: .callout)) + .foregroundStyle(focused ? .white : .white.opacity(0.6)) + .lineLimit(1) + .id(row.value) + .transition(.asymmetric( + insertion: .offset(x: slide).combined(with: .opacity), + removal: .offset(x: -slide).combined(with: .opacity))) + } + .animation(.smooth(duration: 0.22), value: row.value) Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle(.white.opacity(focused ? 0.6 : 0)) } } - .padding(.horizontal, 16) - .padding(.vertical, 13) + .padding(.horizontal, m.rowHPad) + .padding(.vertical, m.rowVPad) // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. .consoleGlass( - RoundedRectangle(cornerRadius: 14, style: .continuous), + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), tint: focused ? Color.brand.opacity(0.30) : nil, interactive: focused) .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) .strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) } .scaleEffect(focused ? 1.0 : 0.98) @@ -193,10 +214,12 @@ struct GamepadSettingsView: View { /// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows /// (never on state captured at wire time). private func adjust(id: String, by delta: Int) -> Bool { - rows.first { $0.id == id }?.adjust(delta) ?? false + lastAdjustDelta = delta + return rows.first { $0.id == id }?.adjust(delta) ?? false } private func activate(id: String) { + lastAdjustDelta = 1 // A always cycles forward rows.first { $0.id == id }?.activate() } @@ -252,6 +275,12 @@ struct GamepadSettingsView: View { detail: "Sharper text and UI at more bandwidth — needs host opt-in and " + "hardware decode.", value: $enable444), + choiceRow( + id: "presenter", icon: "rectangle.stack", label: "Presenter", + detail: "Stage 3 paces presents to the display — lowest display latency. " + + "Stage 2 shows each frame on arrival. Applies from the next session.", + options: SettingsOptions.presenters, current: presenter + ) { presenter = $0 }, choiceRow( id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels", @@ -287,8 +316,7 @@ struct GamepadSettingsView: View { ) { hudPlacement = $0 }, toggleRow( id: "library", icon: "square.grid.2x2", label: "Game library", - detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) " - + "(experimental).", + detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).", value: $libraryEnabled), toggleRow( id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI", diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index c51afe67..22286142 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -37,6 +37,30 @@ enum SettingsOptions { static let hudPlacements: [(label: String, tag: String)] = HUDPlacement.allCases.map { ($0.label, $0.rawValue) } + /// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's + /// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds. + static var presenters: [(label: String, tag: String)] { + var options: [(label: String, tag: String)] = [ + ("Stage 2", "stage2"), + ("Stage 3", "stage3"), + ] + #if DEBUG + options.append(("Stage 1 (debug)", "stage1")) + #endif + return options + } + + /// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs + /// glass pacing, everything else arrival). Views seed their @AppStorage display from this so + /// an untouched picker shows what actually runs. + static var presenterDefault: String { + #if os(tvOS) + "stage3" + #else + "stage2" + #endif + } + /// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value. static let statsVerbosities: [(label: String, tag: String)] = StatsVerbosity.allCases.map { ($0.label, $0.rawValue) } @@ -105,8 +129,8 @@ enum SettingsOptions { return options } - #if os(iOS) || os(macOS) - // MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list) + // MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the + // touch/remote tvOS SettingsView builds its own preset list) /// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`. static let resolutionPresets: [(name: String, w: Int, h: Int)] = [ @@ -124,8 +148,8 @@ enum SettingsOptions { @MainActor static func resolutionModes() -> [(name: String, w: Int, h: Int)] { var native: [(name: String, w: Int, h: Int)] = [] - #if os(iOS) - let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels + #if os(iOS) || os(tvOS) + let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode) native = [("This device", Int(max(bounds.width, bounds.height)), Int(min(bounds.width, bounds.height)))] @@ -145,7 +169,7 @@ enum SettingsOptions { /// the screen can't show), plus any stored custom value so it stays selectable. @MainActor static func refreshRates(including current: Int) -> [Int] { - #if os(iOS) + #if os(iOS) || os(tvOS) let maxHz = UIScreen.main.maximumFramesPerSecond #else let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60 @@ -155,5 +179,4 @@ enum SettingsOptions { if !rates.contains(current) { rates.append(current) } return rates.sorted() } - #endif } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 3fac74bb..4172f0ef 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -24,7 +24,7 @@ struct SettingsView: View { @AppStorage(DefaultsKey.compositor) var compositor = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 - @AppStorage(DefaultsKey.presenter) var presenter = "stage2" + @AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault #if os(macOS) @AppStorage(DefaultsKey.vsync) var vsync = false #endif @@ -33,7 +33,7 @@ struct SettingsView: View { #endif @AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true @AppStorage(DefaultsKey.enable444) var enable444 = true - @AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false + @AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.micEnabled) var micEnabled = true @AppStorage(DefaultsKey.audioChannels) var audioChannels = 2 @@ -43,9 +43,7 @@ struct SettingsView: View { @AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue @AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue @ObservedObject var gamepads = GamepadManager.shared - #if !os(tvOS) @AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true - #endif #if DEBUG && !os(tvOS) @State var showControllerTest = false #endif @@ -284,19 +282,6 @@ struct SettingsView: View { ("4K @ 60", "3840x2160x60"), ] - /// Stage-2 vs stage-3 present pacing (see SettingsView+Sections' presenterSection for the - /// rationale); the freeze-prone stage-1 diagnostic only ships in DEBUG builds. - private static var presenterOptions: [(label: String, tag: String)] { - var options: [(label: String, tag: String)] = [ - ("Stage 2 (default)", "stage2"), - ("Stage 3 (experimental)", "stage3"), - ] - #if DEBUG - options.append(("Stage 1 (debug)", "stage1")) - #endif - return options - } - private var modeTag: Binding { Binding( get: { "\(width)x\(height)x\(hz)" }, @@ -313,6 +298,12 @@ struct SettingsView: View { Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" }) } + /// The gamepad-UI switch as an on/off row (same shape as HDR above) — the escape hatch back + /// to this focus-engine home for someone who prefers it with a controller connected. + private var gamepadUIEnabledTag: Binding { + Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" }) + } + private var tvBody: some View { let currentTag = "\(width)x\(height)x\(hz)" let bounds = UIScreen.main.nativeBounds @@ -338,7 +329,7 @@ struct SettingsView: View { selection: $audioChannels) if bitrateKbps > 1_000_000 { Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") - .font(.geist(12, relativeTo: .caption)) + .font(.geist(20, relativeTo: .caption)) // TV-legible caption size .foregroundStyle(.orange) .multilineTextAlignment(.center) } @@ -347,7 +338,7 @@ struct SettingsView: View { selection: $compositor) TVSelectionRow( title: "Presenter", - options: Self.presenterOptions, + options: SettingsOptions.presenters, selection: $presenter) TVSelectionRow( title: "10-bit HDR", @@ -355,7 +346,7 @@ struct SettingsView: View { Text("The host creates a virtual output at exactly this mode — native " + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor " + "is honored only if available on the host.") - .font(.geist(12, relativeTo: .caption)) + .font(.geist(20, relativeTo: .caption)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.top, 8) @@ -375,8 +366,11 @@ struct SettingsView: View { TVSelectionRow( title: "Controller type", options: SettingsOptions.padTypes, selection: $gamepadType) + TVSelectionRow( + title: "Gamepad-optimized browsing", + options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag) Text(Self.controllersFooter) - .font(.geist(12, relativeTo: .caption)) + .font(.geist(20, relativeTo: .caption)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.top, 8) diff --git a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift index d025159d..bfa111c7 100644 --- a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift +++ b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift @@ -82,20 +82,36 @@ private struct ConsoleGlass: ViewModifier { var interactive = false func body(content: Content) -> some View { - if #available(iOS 26, macOS 26, tvOS 26, *) { + #if os(tvOS) + // ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these + // surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the + // Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights + // the 10-foot platform). The tint rides an overlay so the focused row keeps its wash. + content.background { + shape.fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + .overlay { + if let tint { shape.fill(tint) } + } + } + #else + if #available(iOS 26, macOS 26, *) { content.glassEffect(glass, in: shape) } else { content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) } } + #endif } - @available(iOS 26, macOS 26, tvOS 26, *) + #if !os(tvOS) + @available(iOS 26, macOS 26, *) private var glass: Glass { var g: Glass = .regular if let tint { g = g.tint(tint) } if interactive { g = g.interactive() } return g } + #endif } extension View { diff --git a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift index 42387612..74d95074 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift @@ -48,7 +48,7 @@ struct PairSheet: View { + "(http://:3000 → Pairing). " + "Pairing verifies both sides at once — no fingerprint comparison " + "needed.") - .font(.geist(16, relativeTo: .callout)) + .font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there) .foregroundStyle(.secondary) .multilineTextAlignment(.center) TVFieldRow( @@ -59,7 +59,7 @@ struct PairSheet: View { ) { editing = .clientName } if let errorText { Text(errorText) - .font(.geist(16, relativeTo: .callout)) + .font(.geist(22, relativeTo: .callout)) .foregroundStyle(.red) } HStack(spacing: 32) { diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index 00727e36..bbdae072 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -263,19 +263,24 @@ public final class SessionAudio { defer { drainDone.signal() } // Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is // stereo-only — and is handed back as interleaved f32 PCM in wire channel order. - while !flag.isStopped { + // Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline). + var alive = true + while alive, !flag.isStopped { + alive = autoreleasepool { () -> Bool in let pcm: PunktfunkConnection.AudioPCM? do { pcm = try connection.nextAudioPcm(timeoutMs: 100) } catch { - break // session closed + return false // session closed } - guard let pcm, pcm.frameCount > 0 else { continue } + guard let pcm, pcm.frameCount > 0 else { return true } pcm.samples.withUnsafeBufferPointer { p in if let base = p.baseAddress { ring.write(base, count: pcm.frameCount * pcm.channels) } } + return true + } } } thread.name = "punktfunk-audio" diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 48fed910..6933f820 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -48,6 +48,23 @@ public final class GamepadCapture { /// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate). private static let motionIntervalNs: UInt64 = 4_000_000 + /// The cross-client controller escape chord (pf-client-core's `ESCAPE_CHORD`): + /// L1+R1+Start+Select held together — four simultaneous buttons no game uses, so normal + /// play can't trip it. Held for `disconnectHold` it ends the session via + /// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is + /// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release + /// capture) has no Apple equivalent worth wiring — macOS has ⌃⌥⇧Q/D, touch has the HUD. + private static let escapeChord: UInt32 = + GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back + /// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client. + private static let disconnectHold: TimeInterval = 1.5 + private var chordTimer: Timer? + /// Fired ON MAIN once the escape chord has been held `disconnectHold` — the session owner + /// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a + /// stream with a controller: B/Menu presses are deliberately swallowed during a session so + /// gameplay can't end it (see ContentView's tvOS session branch). + public var onDisconnectRequest: (() -> Void)? + public init(connection: PunktfunkConnection, manager: GamepadManager) { self.connection = connection self.manager = manager @@ -165,6 +182,7 @@ public final class GamepadCapture { private func sync(_ g: GCExtendedGamepad) { guard !suspended else { return } let newButtons = Self.buttonMask(g) + updateEscapeChord(newButtons) let changed = newButtons ^ buttons if changed != 0 { for bit in GamepadWire.allButtons where changed & bit != 0 { @@ -297,7 +315,26 @@ public final class GamepadCapture { /// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The /// host's virtual pad returns to rest instead of running with the last state. + /// Arm the disconnect timer when the full chord lands, disarm the moment any of the four + /// releases. Events only arrive on state CHANGES, so a held chord needs the timer — the + /// handler won't fire again until something moves. + private func updateEscapeChord(_ newButtons: UInt32) { + let held = newButtons & Self.escapeChord == Self.escapeChord + if held, chordTimer == nil { + let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in + Task { @MainActor in self?.onDisconnectRequest?() } + } + RunLoop.main.add(timer, forMode: .common) + chordTimer = timer + } else if !held, chordTimer != nil { + chordTimer?.invalidate() + chordTimer = nil + } + } + private func releaseAll() { + chordTimer?.invalidate() + chordTimer = nil for bit in GamepadWire.allButtons where buttons & bit != 0 { connection.send(.gamepadButton(bit, down: false, pad: 0)) } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index 3a08113c..a0ecc877 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -74,7 +74,11 @@ public final class GamepadFeedback { // session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and // let rumble own the wait elsewhere; on an Xbox session it stays nonblocking. let thread = Thread { [connection, flag, drainDone, weak self] in - while !flag.isStopped { + // Per-iteration autorelease pool: no runloop on this thread, and the haptics/HID + // rendering below autoreleases ObjC temporaries. `false` = session over. + var alive = true + while alive, !flag.isStopped { + alive = autoreleasepool { () -> Bool in do { // Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds // the connection's shared feedback lock for its whole wait; the video pump drains @@ -106,12 +110,14 @@ public final class GamepadFeedback { self?.render(ev) burst += 1 } + return true } catch { - break // .closed (or fatal) — the session is over + return false // .closed (or fatal) — the session is over + } } // ~8 ms poll cadence (≈125 Hz), slept OUTSIDE the feedback lock — low rumble/HID // latency without holding the lock the HDR-meta drain needs. - if !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) } + if alive, !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) } } drainDone.signal() } diff --git a/clients/apple/Sources/PunktfunkKit/Input/SiriRemotePointer.swift b/clients/apple/Sources/PunktfunkKit/Input/SiriRemotePointer.swift new file mode 100644 index 00000000..f1799596 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Input/SiriRemotePointer.swift @@ -0,0 +1,173 @@ +// The Siri Remote as a pointing device during a tvOS streaming session — the remote's touch +// surface drives the HOST cursor (relative deltas, like a laptop trackpad), a surface press +// clicks (left button), and Play/Pause right-clicks. It also owns the remote's DELIBERATE +// session exit: hold Back/Menu ≥ `disconnectHold`. A short Back press does nothing — the +// UIKit menu press it also generates is swallowed by ContentView's session branch, so neither +// a trackpad fumble nor a game-controller B press can end the session (the pad's exit is the +// L1+R1+Start+Select chord in GamepadCapture). +// +// The remote is read through GameController as a GCMicroGamepad with +// `reportsAbsoluteDpadValues = true`: the dpad axes then report the finger's ABSOLUTE position +// on the surface (±1, +y up) while touched, and snap to exactly (0, 0) on lift. Successive +// positions are differenced into relative mouse deltas; the exact-zero snap is treated as a +// lift (a real touch at the mathematical centre is measure-zero, and one dropped delta there +// is imperceptible). Handlers (not a poll) — the same in-session delivery GamepadCapture +// relies on. +// +// Lifecycle mirrors GamepadCapture: started by SessionModel when streaming begins (never +// during the trust prompt), stopped on disconnect; held buttons are released on stop so the +// host never keeps a stuck click. + +#if os(tvOS) +import Foundation +import GameController +import UIKit + +@MainActor +public final class SiriRemotePointer { + private let connection: PunktfunkConnection + private var observers: [NSObjectProtocol] = [] + private var bound: GCController? + /// Finger position (±1 axes) at the last dpad callback while touched; nil = lifted. + private var lastTouch: (x: Float, y: Float)? + /// Wire buttons currently held (1 = left, 3 = right) — released on stop/unbind. + private var heldButtons: Set = [] + /// When Back/Menu went down; a release after `disconnectHold` fires the exit. + private var menuDownAt: Date? + + /// Hold Back/Menu at least this long (then release) to end the session. Shorter than the + /// controller chord's 1.5 s — the remote has no way to trip this during gameplay. + private static let disconnectHold: TimeInterval = 1.0 + /// A full edge-to-edge swipe moves the host cursor about this many pixels. The surface is + /// small; two comfortable swipes should cross a 1080p desktop. + private static let pointerScale: Float = 1100 + /// Largest single-callback finger travel accepted as real motion (surface units; the axes + /// span ±1, so 0.4 ≈ a fifth of the pad). On RELEASE the hardware slides the reported + /// position back to (0, 0) through intermediate callbacks — naive differencing turns that + /// tail into reverse deltas that RETRACE the whole swipe, so the cursor springs back to its + /// anchor and the pointer feels absolute. Real finger motion arrives as many small steps + /// (even a fast flick stays well under this per callback); the release tail arrives as one + /// or two huge jumps — discard those (the anchor still follows, so nothing accumulates). + private static let maxStep: Float = 0.4 + + /// Fired ON MAIN after Back/Menu was held ≥ `disconnectHold` and released. + public var onDisconnectRequest: (() -> Void)? + + public init(connection: PunktfunkConnection) { + self.connection = connection + } + + public func start() { + observers.append(NotificationCenter.default.addObserver( + forName: .GCControllerDidConnect, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.rebind() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .GCControllerDidDisconnect, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.rebind() } + }) + rebind() + } + + public func stop() { + observers.forEach(NotificationCenter.default.removeObserver(_:)) + observers.removeAll() + bind(nil) + } + + /// The Siri Remote is the non-extended controller carrying a microGamepad — a full gamepad + /// (which also EXPOSES a microGamepad view of itself) must never be captured here, its + /// buttons belong to GamepadCapture. + private func rebind() { + let remote = GCController.controllers().first { + $0.extendedGamepad == nil && $0.microGamepad != nil + } + bind(remote) + } + + private func bind(_ controller: GCController?) { + guard controller !== bound else { return } + if let old = bound?.microGamepad { + old.dpad.valueChangedHandler = nil + old.buttonA.pressedChangedHandler = nil + old.buttonX.pressedChangedHandler = nil + old.buttonMenu.pressedChangedHandler = nil + } + releaseHeld() + lastTouch = nil + menuDownAt = nil + bound = controller + guard let micro = controller?.microGamepad else { return } + + // Absolute finger position instead of the emulated dpad — the raw surface is what a + // trackpad needs. Rotation stays off: the remote's natural grip is the coordinate frame. + micro.reportsAbsoluteDpadValues = true + micro.allowsRotation = false + + micro.dpad.valueChangedHandler = { [weak self] _, x, y in + MainActor.assumeIsolated { self?.touchMoved(x: x, y: y) } + } + // Surface click = left button; Play/Pause = right (the remote's only spare face button). + micro.buttonA.pressedChangedHandler = { [weak self] _, _, pressed in + MainActor.assumeIsolated { self?.setButton(1, down: pressed) } + } + micro.buttonX.pressedChangedHandler = { [weak self] _, _, pressed in + MainActor.assumeIsolated { self?.setButton(3, down: pressed) } + } + micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in + MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) } + } + } + + private func touchMoved(x: Float, y: Float) { + // Exact (0, 0) is the lift snap — drop the anchor so the next touch starts a fresh + // gesture instead of a jump-delta from the old position. + guard x != 0 || y != 0 else { + lastTouch = nil + return + } + defer { lastTouch = (x, y) } + guard let last = lastTouch else { return } // first contact anchors, moves nothing + let stepX = x - last.x + let stepY = y - last.y + // The release tail (and any tracking glitch) shows up as a single impossible jump — + // see `maxStep`. Skip the emission; the deferred anchor update above still follows the + // reported position, so the gesture cleanly re-anchors instead of retracing. + guard abs(stepX) < Self.maxStep, abs(stepY) < Self.maxStep else { return } + let dx = stepX * Self.pointerScale / 2 // axes span ±1 → full swipe = 2.0 + let dy = -stepY * Self.pointerScale / 2 // GC +y is up; mouse +y is down + let ix = Int32(dx.rounded()) + let iy = Int32(dy.rounded()) + guard ix != 0 || iy != 0 else { return } + connection.send(.mouseMove(dx: ix, dy: iy)) + } + + private func setButton(_ button: UInt32, down: Bool) { + if down { heldButtons.insert(button) } else { heldButtons.remove(button) } + connection.send(.mouseButton(button, down: down)) + } + + private func menuChanged(pressed: Bool) { + if pressed { + menuDownAt = Date() + return + } + let heldFor = menuDownAt.map { Date().timeIntervalSince($0) } ?? 0 + menuDownAt = nil + if heldFor >= Self.disconnectHold { + onDisconnectRequest?() + } + // A short press is deliberately nothing: the accompanying UIKit menu press is swallowed + // in ContentView, and forwarding it as a host key would make trackpad fumbles type. + } + + private func releaseHeld() { + for button in heldButtons { + connection.send(.mouseButton(button, down: false)) + } + heldButtons.removeAll() + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkKit/Support/Licenses.swift b/clients/apple/Sources/PunktfunkKit/Support/Licenses.swift index f1174153..23c96a26 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/Licenses.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/Licenses.swift @@ -13,14 +13,14 @@ public enum Licenses { return text } - /// punktfunk's own license — MIT OR Apache-2.0, at your option. + /// Punktfunk's own license — MIT OR Apache-2.0, at your option. public static var appLicense: String { let mit = resource("LICENSE-MIT") let apache = resource("LICENSE-APACHE") if mit.isEmpty && apache.isEmpty { - return "punktfunk is licensed under MIT OR Apache-2.0, at your option." + return "Punktfunk is licensed under MIT OR Apache-2.0, at your option." } - return "punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n" + return "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n" + "================================ MIT ================================\n\n" + mit + "\n\n============================== Apache-2.0 ==============================\n\n" @@ -51,11 +51,27 @@ public enum Licenses { /// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay /// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n"; /// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once. - public static let thirdPartyNoticesChunks: [String] = { - let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false) - let chunkSize = 200 - return stride(from: 0, to: lines.count, by: chunkSize).map { start in - lines[start.. [String] { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + return stride(from: 0, to: lines.count, by: chunkLines).map { start in + lines[start.. PresenterChoice { let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting switch raw { - case "stage1": return allowStage1 ? .stage1 : .stage2 + case "stage1": return allowStage1 ? .stage1 : platformDefault + case "stage2": return .stage2 case "stage3": return .stage3 - default: return .stage2 + default: return platformDefault } } + + /// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction — + /// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue + /// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there). + /// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the + /// proven default; ProMotion/desktop panels out-tick the stream often enough to drain). + static var platformDefault: PresenterChoice { + #if os(tvOS) + .stage3 + #else + .stage2 + #endif + } } final class SessionPresenter { @@ -179,6 +197,13 @@ final class SessionPresenter { stage2?.setDrawableTarget(CGSize( width: (fit.width * contentsScale).rounded(), height: (fit.height * contentsScale).rounded())) + #if os(tvOS) + // Push the display's live EDR headroom alongside: > 1 means the TV is composited in an + // HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR + // frames flip to PQ passthrough. The stream view also re-layouts on mode-switch/screen- + // mode notifications, so a mid-session switch reaches here without a bounds change. + stage2?.setDisplayHeadroom(UIScreen.main.currentEDRHeadroom) + #endif } /// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index ac7188fe..f21fcc02 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -358,7 +358,12 @@ public final class Stage2Pipeline { // decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a // resolution-ceiling miss). End cleanly instead of looping on a black screen. var decodeFailRun = 0 - while !token.isStopped { + // Every iteration drains its own autorelease pool: this thread has no runloop, so + // autoreleased VT/CM temporaries would otherwise accumulate until session end. + // `false` = session over — exit the loop (the closure can't `break` across itself). + var alive = true + while alive, !token.isStopped { + alive = autoreleasepool { () -> Bool in do { // Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the // decoder conceals the reference-missing deltas — often WITHOUT an error callback — @@ -378,13 +383,13 @@ public final class Stage2Pipeline { if let meta = try? connection.nextHdrMeta(timeoutMs: 0) { presenter.setHdrMeta(meta) } - guard let au = try connection.nextAU(timeoutMs: 100) else { continue } + guard let au = try connection.nextAU(timeoutMs: 100) else { return true } onFrame?(au) if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) { format = f // refreshed on every IDR (mode changes included) awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete } - guard let f = format, !token.isStopped else { continue } + guard let f = format, !token.isStopped else { return true } if decoder.decode(au: au, format: f) { decodeFailRun = 0 } else { @@ -397,12 +402,14 @@ public final class Stage2Pipeline { // recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session. if connection.isChroma444, decodeFailRun >= 180 { if !token.isStopped { onSessionEnd?() } - break + return false } } + return true } catch { if !token.isStopped { onSessionEnd?() } - break // session closed + return false // session closed + } } } } @@ -435,10 +442,14 @@ public final class Stage2Pipeline { let gate: PresentGate? = pacing == .glass ? PresentGate() : nil let renderThread = Thread { defer { renderStopped.signal() } - while !token.isStopped { + // Every iteration drains its own autorelease pool (`return` = the old `continue`): + // this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable — + // without a per-iteration pool every presented frame's drawable object (plus its + // texture-descriptor/array retinue, ~2 MB/min at 120 fps) piles up until session end. + while !token.isStopped { autoreleasepool { if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut { debugStats?.flushIfDue(ring: ring, gate: gate) - continue + return } // Stage-3: while a present is in flight, don't take from the ring at all — frames // keep coalescing there (newest wins, the intended drop point) and the presented @@ -447,13 +458,13 @@ public final class Stage2Pipeline { if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) { debugStats?.gatedWake() debugStats?.flushIfDue(ring: ring, gate: gate) - continue + return } guard !token.isStopped, let frame = ring.take() else { gate?.release() // armed but nothing to render — don't hold the gate stale debugStats?.emptyWake() debugStats?.flushIfDue(ring: ring, gate: gate) - continue + return } // V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒ // immediate — see VsyncClock). OFF: flip as soon as the GPU finishes. @@ -488,7 +499,7 @@ public final class Stage2Pipeline { ring.putBack(frame) } debugStats?.flushIfDue(ring: ring, gate: gate) - } + } } } renderThread.name = "punktfunk-stage2-render" renderThread.qualityOfService = .userInteractive @@ -512,6 +523,13 @@ public final class Stage2Pipeline { presenter.setDrawableTarget(size) } + /// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen` + /// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on + /// it; see `MetalVideoPresenter.setDisplayHeadroom`. + public func setDisplayHeadroom(_ headroom: CGFloat) { + presenter.setDisplayHeadroom(headroom) + } + /// Stop the pump + render thread (≤ one poll timeout each) and drop the decode session. MAIN /// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline /// (the stop is permanent). diff --git a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift index 5bab2cc2..8d193aa7 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift @@ -47,7 +47,12 @@ final class StreamPump { var awaitingIDR = false var awaitingSince = Date.distantPast // when the current recovery began (for the resume log) var wasFailed = false - while !token.isStopped { + // Every iteration drains its own autorelease pool: this thread has no runloop, so + // autoreleased CM/layer temporaries would otherwise accumulate until session end. + // `false` = session over — exit the loop (the closure can't `break` across itself). + var alive = true + while alive, !token.isStopped { + alive = autoreleasepool { () -> Bool in do { // Loss recovery (the primary path). Under the host's infinite GOP the only // recovery keyframe is one we request. The reassembler drops unrecoverable AUs @@ -69,7 +74,7 @@ final class StreamPump { } if awaitingIDR { recovery.request() } - guard let au = try connection.nextAU(timeoutMs: 100) else { continue } + guard let au = try connection.nextAU(timeoutMs: 100) else { return true } onFrame?(au) let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data) if let f = idrFormat { @@ -97,13 +102,15 @@ final class StreamPump { guard let f = format, let sample = connection.videoCodec.sampleBuffer(au: au, format: f), !token.isStopped // don't enqueue a stale frame after a restart - else { continue } + else { return true } layer.enqueue(sample) + return true } catch { if !token.isStopped { onSessionEnd?() } - break // session closed + return false // session closed + } } } } diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index db90a414..ee247fb1 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -36,6 +36,9 @@ import PunktfunkCore import SwiftUI import UIKit import os +#if os(tvOS) +import AVKit // AVDisplayManager — the per-session display-mode (HDR10/refresh) request +#endif /// Same diagnostic switch as InputCapture (PUNKTFUNK_INPUT_DEBUG=1): on iOS we log the /// resolved pointer-lock state each time capture engages, so the user can see whether the @@ -108,7 +111,20 @@ public struct StreamView: UIViewControllerRepresentable { } } -public final class StreamViewController: UIViewController { +#if os(tvOS) +/// tvOS: a GCEventViewController with `controllerUserInteractionEnabled = false` routes game- +/// controller (and Siri Remote) input EXCLUSIVELY to the GameController framework while the +/// stream is up. Without it a pad's B/Menu press doubles as a UIKit menu press — which ended +/// the session (or suspended the whole app) from ordinary gameplay; a SwiftUI +/// `.onExitCommand {}` swallow proved unreliable with nothing focusable on screen. Every +/// in-session exit is GC-level by design: the pad's escape chord (GamepadCapture) and the +/// remote's hold-Back (SiriRemotePointer). +public typealias StreamViewControllerBase = GCEventViewController +#else +public typealias StreamViewControllerBase = UIViewController +#endif + +public final class StreamViewController: StreamViewControllerBase { public private(set) var connection: PunktfunkConnection? private var observers: [NSObjectProtocol] = [] /// Record the unified latency stages (end-to-end / decode / display) when the stage-2 @@ -119,6 +135,11 @@ public final class StreamViewController: UIViewController { /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the /// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback. private let presenter = SessionPresenter() + #if os(tvOS) + /// The window's display manager the session's mode request was set on — held weakly so + /// stop() can clear the request even after the view has left the window. + private weak var sessionDisplayManager: AVDisplayManager? + #endif #if os(iOS) private var inputCapture: InputCapture? fileprivate var captured = false @@ -157,6 +178,12 @@ public final class StreamViewController: UIViewController { public override func loadView() { view = StreamLayerUIView() + #if os(tvOS) + // Kill the pad/remote → UIKit press path at the source for the whole session (see the + // GCEventViewController typealias above). GC delivery is untouched: GamepadCapture + // forwards the pad, SiriRemotePointer drives the pointer and owns the remote exit. + controllerUserInteractionEnabled = false + #endif // Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g. // moving to an external display at a different scale) — the iOS analogue of macOS's // viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it @@ -230,6 +257,18 @@ public final class StreamViewController: UIViewController { } #endif + #if os(tvOS) + // The GCEventViewController's interaction flag applies to the deepest such controller + // CONTAINING THE FIRST RESPONDER — inside SwiftUI's hosting-controller sandwich that is not + // guaranteed to be us unless we anchor the responder chain here explicitly. + public override var canBecomeFirstResponder: Bool { true } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + becomeFirstResponder() + } + #endif + func start( connection: PunktfunkConnection, onFrame: (@Sendable (AccessUnit) -> Void)?, @@ -342,6 +381,19 @@ public final class StreamViewController: UIViewController { setCaptured(true) // entering a session is the deliberate "capture me" moment } #endif + + #if os(tvOS) + // The TV's mode switch (requested in applyDisplayCriteriaIfNeeded) completes + // asynchronously, and a dynamic-range-only switch doesn't re-layout by itself — + // re-layout on the switch/mode notifications so the presenter sees the new EDR + // headroom immediately (layout pushes UIScreen.currentEDRHeadroom down). + observers.append(NotificationCenter.default.addObserver( + forName: .AVDisplayManagerModeSwitchEnd, object: nil, queue: .main + ) { [weak self] _ in self?.layoutMetalLayer() }) + observers.append(NotificationCenter.default.addObserver( + forName: UIScreen.modeDidChangeNotification, object: nil, queue: .main + ) { [weak self] _ in self?.layoutMetalLayer() }) + #endif } func stop() { @@ -360,6 +412,12 @@ public final class StreamViewController: UIViewController { streamView.onScroll = nil streamView.currentHostMode = nil #endif + #if os(tvOS) + // Return the TV to the user's preferred mode — the home screen must not stay in the + // session's HDR10/refresh mode. + sessionDisplayManager?.preferredDisplayCriteria = nil + sessionDisplayManager = nil + #endif presenter.stop() connection = nil } @@ -367,8 +425,50 @@ public final class StreamViewController: UIViewController { public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() layoutMetalLayer() + #if os(tvOS) + applyDisplayCriteriaIfNeeded() + #endif } + #if os(tvOS) + /// Ask the TV for a display mode matching the session — HDR10 at the stream's refresh rate — + /// via AVDisplayManager, the tvOS mechanism custom renderers use for HDR output (AVFoundation + /// playback layers do this implicitly). Honored only when the user allows matching (tvOS + /// Settings → Video and Audio → Match Content); the presenter reads the RESULT off UIScreen's + /// EDR headroom (pushed in SessionPresenter.layout) and keeps the in-shader tone-map whenever + /// the switch never lands, so an SDR-composited display can't show blown-out PQ either way. + /// Applied once per session, as soon as the window and the negotiated mode both exist; the + /// stop() teardown clears it. + private func applyDisplayCriteriaIfNeeded() { + guard let manager = view.window?.avDisplayManager, let connection, + manager.preferredDisplayCriteria == nil, + UserDefaults.standard.object(forKey: DefaultsKey.hdrEnabled) as? Bool ?? true + else { return } + let mode = connection.currentMode() + guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return } + // A synthetic HDR10-HEVC format description carrying the negotiated mode — what the + // stream decodes to. AVDisplayCriteria(refreshRate:formatDescription:) matches the + // display to it (tvOS 17+, our deployment floor). + let ext: [CFString: Any] = [ + kCMFormatDescriptionExtension_ColorPrimaries: + kCMFormatDescriptionColorPrimaries_ITU_R_2020, + kCMFormatDescriptionExtension_TransferFunction: + kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ, + kCMFormatDescriptionExtension_YCbCrMatrix: + kCMFormatDescriptionYCbCrMatrix_ITU_R_2020, + ] + var desc: CMFormatDescription? + CMVideoFormatDescriptionCreate( + allocator: kCFAllocatorDefault, codecType: kCMVideoCodecType_HEVC, + width: Int32(mode.width), height: Int32(mode.height), + extensions: ext as CFDictionary, formatDescriptionOut: &desc) + guard let desc else { return } + manager.preferredDisplayCriteria = AVDisplayCriteria( + refreshRate: Float(mode.refreshHz), formatDescription: desc) + sessionDisplayManager = manager + } + #endif + /// The display scale to render the metal drawable at. `traitCollection.displayScale` is the /// canonical render scale and is reliable once the controller is in the hierarchy; /// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which