feat(apple): the gamepad UI comes to tvOS - focus-driven, with real session controls
ci / rust (push) Failing after 50s
ci / web (push) Successful in 57s
decky / build-publish (push) Successful in 14s
ci / docs-site (push) Successful in 1m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
flatpak / build-publish (push) Failing after 2m14s
ci / bench (push) Successful in 5m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5m46s
docker / deploy-docs (push) Successful in 23s
windows-host / package (push) Successful in 11m40s
arch / build-publish (push) Successful in 18m10s
deb / build-publish (push) Successful in 18m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
android / android (push) Successful in 21m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m3s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m2s
release / apple (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:58:35 +02:00
parent 1fcf9e11ec
commit 3ba19f28a2
29 changed files with 1208 additions and 242 deletions
@@ -376,7 +376,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
@@ -412,7 +412,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
@@ -449,7 +449,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -490,7 +490,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -522,7 +522,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos; SDKROOT = appletvos;
@@ -552,7 +552,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos; SDKROOT = appletvos;
@@ -68,25 +68,28 @@ struct ContentView: View {
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar /// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
/// never covers the video. /// never covers the video.
@State private var isFullscreen = false @State private var isFullscreen = false
#endif
#if os(macOS) || os(tvOS)
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability /// 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 /// 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 /// 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 @State private var showShortcutHint = false
#endif #endif
#if !os(macOS) #if !os(macOS)
@State private var showSettings = false @State private var showSettings = false
#endif #endif
#if os(iOS) || os(macOS)
// A connected controller (+ the Settings toggle) swaps the whole home screen for // A connected controller (+ the Settings toggle) swaps the whole home screen for
// GamepadHomeView instead of retrofitting HomeView's touch/desktop UI see `home` below. // 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 @ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool { private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive( GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
} }
#endif
var body: some View { var body: some View {
Group { Group {
@@ -108,7 +111,7 @@ struct ContentView: View {
.onChange(of: model.phase) { _, phase in .onChange(of: model.phase) { _, phase in
switch phase { switch phase {
case .streaming: case .streaming:
#if os(macOS) #if os(macOS) || os(tvOS)
showShortcutHint = true // the 6 s shortcut banner, per session start showShortcutHint = true // the 6 s shortcut banner, per session start
#endif #endif
// A session actually started remember it on the card ("Connected ago" // 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) }) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
} }
} }
#elseif os(iOS) #else
Group { Group {
if gamepadUIActive { if gamepadUIActive {
GamepadHomeView( GamepadHomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker, libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0) }, connectDiscovered: connectDiscovered) 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 { } else {
HomeView( HomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
@@ -295,14 +315,6 @@ struct ContentView: View {
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) 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 #endif
} }
@@ -362,11 +374,14 @@ struct ContentView: View {
#else #else
.background(Color.black) .background(Color.black)
.ignoresSafeArea() .ignoresSafeArea()
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable // SWALLOW Menu/B during a session a game controller's B button ALSO surfaces as this
// disconnect control during play, the controller's buttons flow to the host instead of // UIKit menu press, so the old instant-disconnect here ended the session on every B
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the // press in gameplay. The button still reaches the host via GamepadCapture; the
// host as Start the Siri Remote is the intended disconnect path. // DELIBERATE exits are holding the remote's Back 1 s (SiriRemotePointer) and holding
.onExitCommand { model.disconnect() } // 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 #endif
} }
@@ -418,17 +433,18 @@ struct ContentView: View {
} }
.animation(.smooth(duration: 0.28), value: statsVerbosity) .animation(.smooth(duration: 0.28), value: statsVerbosity)
} }
#if os(macOS) #if os(macOS) || os(tvOS)
// The start-of-stream shortcut banner (Windows-client parity): the full // The start-of-stream shortcut banner (Windows-client parity): the platform's
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of // 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 // 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 // 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) { .overlay(alignment: .bottom) {
if captureEnabled && showShortcutHint { if captureEnabled && showShortcutHint {
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · " Text(Self.shortcutHintText)
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats") .font(.geist(Self.shortcutHintFont, relativeTo: .caption))
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 8) .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 // MARK: - Connect
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) { private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
@@ -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 / // 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; // 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 // 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 // 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. // 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 PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View { struct GamepadAddHostView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -37,22 +39,22 @@ struct GamepadAddHostView: View {
isActive: editing == nil isActive: editing == nil
) { row, focused in ) { row, focused in
rowView(row, focused: focused) rowView(row, focused: focused)
.frame(maxWidth: 620) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) { .safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("Add Host") Text("Add Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white) .foregroundStyle(.white)
if !compact { if !compact {
Text("Hosts on this network appear automatically — add one by address " Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.") + "for everything else.")
.font(.geist(13, relativeTo: .caption)) .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.frame(maxWidth: 440) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
} }
} }
.padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.top, gamepadTitleTopPadding(compact: compact))
@@ -75,10 +77,38 @@ struct GamepadAddHostView: View {
.onChange(of: port) { _, value in .onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) } 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 { @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 { if let editing {
VStack(spacing: 10) { VStack(spacing: 10) {
GamepadKeyboard( GamepadKeyboard(
@@ -104,6 +134,7 @@ struct GamepadAddHostView: View {
]) ])
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
#endif
} }
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc /// 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 { private var closeButton: some View {
Button { dismiss() } label: { Button { dismiss() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 34, height: 34) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
.contentShape(Circle()) .contentShape(Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.keyboardShortcut(.cancelAction) #if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Cancel") .accessibilityLabel("Cancel")
} }
@@ -142,19 +175,20 @@ struct GamepadAddHostView: View {
} }
private func rowView(_ row: Row, focused: Bool) -> some View { private func rowView(_ row: Row, focused: Bool) -> some View {
HStack(spacing: 14) { let m = GamepadFormMetrics.self
return HStack(spacing: 14) {
if row.isAction { if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill") 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)) .foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} else { } else {
Text(row.label) Text(row.label)
.font(.geist(16, .semibold, relativeTo: .body)) .font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white) .foregroundStyle(.white)
Spacer(minLength: 12) Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value) 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) .foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
.lineLimit(1) .lineLimit(1)
.truncationMode(.head) // keep the end of a long address visible while typing .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. // The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle() Rectangle()
.fill(Color.brand) .fill(Color.brand)
.frame(width: 2, height: 18) .frame(width: 2, height: m.labelFont + 2)
} }
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, m.rowHPad)
.padding(.vertical, 13) .padding(.vertical, m.rowVPad)
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row // 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. // takes the brand wash, and the edited row keeps its brand caret border.
.consoleGlass( .consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous), RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil, tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
interactive: focused) interactive: focused)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous) RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder( .strokeBorder(
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06), editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
lineWidth: 1) lineWidth: 1)
@@ -235,5 +269,41 @@ struct GamepadAddHostView: View {
default: return nil 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 #endif
@@ -1,6 +1,11 @@
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the // 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 // 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)` // The scrolling is pure native SwiftUI `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live // snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
@@ -24,7 +29,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable { struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
let items: [Item] let items: [Item]
@@ -54,6 +59,11 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
@State private var input = GamepadMenuInput(manager: .shared) @State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(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 /// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
/// while the gamepad is driving that's the whole desync fix. /// while the gamepad is driving that's the whole desync fix.
@State private var cursor = 0 @State private var cursor = 0
@@ -81,26 +91,72 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
let inset = max(0, (geo.size.width - itemWidth) / 2) let inset = max(0, (geo.size.width - itemWidth) / 2)
ScrollViewReader { proxy in
ScrollView(.horizontal) { ScrollView(.horizontal) {
HStack(spacing: spacing) { HStack(spacing: spacing) {
ForEach(items) { item in 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) card(item)
.frame(width: itemWidth) .frame(width: itemWidth)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { tap(item) } .onTapGesture { tap(item) }
#endif
} }
} }
.frame(height: geo.size.height) // fill so shorter cards center vertically .frame(height: geo.size.height) // fill so shorter cards center vertically
.scrollTargetLayout() .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) .scrollPosition(id: $scrolledID)
.scrollTargetBehavior(.viewAligned) .scrollTargetBehavior(.viewAligned)
#endif
// .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden // .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden
// and paints a scroller across the console strip. // and paints a scroller across the console strip.
.scrollIndicators(.never) .scrollIndicators(.never)
.scrollClipDisabled() // let the focused card scale up past the strip bounds .scrollClipDisabled() // let the focused card scale up past the strip bounds
.safeAreaPadding(.horizontal, inset) .safeAreaPadding(.horizontal, inset)
.offset(x: bumpOffset) .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)
}
}
// 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
}
} }
.sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
@@ -128,13 +184,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a // 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 // 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 // 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 .onChange(of: scrolledID) { _, newValue in
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return } guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
guard let idx = index(of: newValue), idx != cursor else { return } guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx cursor = idx
selection = newValue selection = newValue
} }
#endif
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the // 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 // current `items` value (a plain array unlike an observed object it would otherwise go
// stale in the closures stored on `input`). // stale in the closures stored on `input`).
@@ -147,12 +206,20 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
// MARK: - Input wiring // MARK: - Input wiring
private func wire() { 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.onMove = { move($0) }
input.onConfirm = { activate() } input.onConfirm = { activate() }
input.onSecondary = onSecondary input.onSecondary = onSecondary
input.onTertiary = onTertiary input.onTertiary = onTertiary
input.onBack = onBack input.onBack = onBack
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
#endif
} }
private func move(_ direction: GamepadMenuInput.Direction) { private func move(_ direction: GamepadMenuInput.Direction) {
@@ -212,9 +279,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
private func activate() { private func activate() {
guard cursor >= 0, cursor < items.count else { return } 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 activateTick &+= 1
haptics.confirm() haptics.confirm()
onActivate(items[cursor]) onActivate(item)
} }
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping /// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
@@ -257,6 +329,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
scrolledID = id scrolledID = id
selection = 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) { private func boundaryBump(forward: Bool) {
@@ -2,11 +2,12 @@
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the // GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
// controller-glyph hint bar, and the connected-controller status chip. One look across every // 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. // 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 PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
/// The active controller's real glyph for a button (Xbox "A", DualSense , ) via /// The active controller's real glyph for a button (Xbox "A", DualSense , ) via
@@ -31,6 +32,51 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
#endif #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. /// One glyph + label cell in a hint bar.
struct GamepadHint: Identifiable { struct GamepadHint: Identifiable {
let glyph: String let glyph: String
@@ -45,21 +91,32 @@ struct GamepadHint: Identifiable {
struct GamepadHintBar: View { struct GamepadHintBar: View {
let hints: [GamepadHint] 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 { var body: some View {
HStack(spacing: 18) { HStack(spacing: 18) {
ForEach(hints) { hint in ForEach(hints) { hint in
HStack(spacing: 7) { HStack(spacing: 7) {
Image(systemName: hint.glyph) Image(systemName: hint.glyph)
.font(.system(size: 19)) .font(.system(size: Self.glyphFont))
.foregroundStyle(.white) .foregroundStyle(.white)
Text(hint.text) Text(hint.text)
} }
.fixedSize() // keep glyph + label together; never truncate a hint mid-word .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)) .foregroundStyle(.white.opacity(0.85))
.padding(13) .padding(Self.pad)
.consoleGlass(Capsule()) .consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) .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 /// "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 /// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes. /// when the pad or its battery state changes.
struct ControllerStatusChip: View { struct ControllerStatusChip: View {
let controller: GamepadManager.DiscoveredController 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 { var body: some View {
HStack(spacing: 7) { HStack(spacing: 7) {
Image(systemName: controller.hasTouchpadAndMotion Image(systemName: controller.hasTouchpadAndMotion
? "playstation.logo" : "gamecontroller.fill") ? "playstation.logo" : "gamecontroller.fill")
.font(.system(size: 12)) .font(.system(size: Self.font))
Text(controller.name) Text(controller.name)
.lineLimit(1) .lineLimit(1)
if let level = controller.batteryLevel { if let level = controller.batteryLevel {
Image(systemName: batterySymbol(level)) Image(systemName: batterySymbol(level))
.font(.system(size: 12)) .font(.system(size: Self.font))
.foregroundStyle(level <= 0.2 && !controller.isCharging .foregroundStyle(level <= 0.2 && !controller.isCharging
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7))) ? 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)) .foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 12) .padding(.horizontal, Self.hPad)
.padding(.vertical, 7) .padding(.vertical, Self.vPad)
.background(Capsule().fill(.white.opacity(0.08))) .background(Capsule().fill(.white.opacity(0.08)))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
} }
@@ -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 // 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 // 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 // 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 // `.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 // 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 // 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 PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host /// 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 connect: (StoredHost) -> Void
let connectDiscovered: (DiscoveredHost) -> Void let connectDiscovered: (DiscoveredHost) -> Void
/// Same experimental gate the touch grid's "Browse Library" context-menu item uses. /// Same gate the touch grid's "Browse Library" context-menu item uses (default ON; the
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false /// Settings "Game library" toggle opts out).
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
#if os(iOS) #if os(iOS)
/// `.compact` in a landscape phone window drives tighter chrome so everything still fits. /// `.compact` in a landscape phone window drives tighter chrome so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass @Environment(\.verticalSizeClass) private var vSizeClass
@@ -104,6 +107,12 @@ struct GamepadHomeView: View {
try? await Task.sleep(for: .seconds(10)) 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` // 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 // 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. // 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 // MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
@ViewBuilder private func hero(for size: CGSize) -> some View { @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) let cardWidth = min(340, size.width * 0.84)
// 48 the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip // 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 // 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.) // 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)) let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
#endif
VStack(spacing: compact ? 8 : 10) { VStack(spacing: compact ? 8 : 10) {
Spacer(minLength: 0) Spacer(minLength: 0)
carousel(cardWidth: cardWidth, cardHeight: cardHeight) carousel(cardWidth: cardWidth, cardHeight: cardHeight)
@@ -145,7 +160,7 @@ struct GamepadHomeView: View {
private var titleBar: some View { private var titleBar: some View {
Text("Select a Host") Text("Select a Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { .overlay(alignment: .trailing) {
@@ -158,6 +173,14 @@ struct GamepadHomeView: View {
} }
} }
private var cardSpacing: CGFloat {
#if os(tvOS)
44
#else
30
#endif
}
// MARK: - Carousel // MARK: - Carousel
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View { private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
@@ -165,7 +188,7 @@ struct GamepadHomeView: View {
items: tiles, items: tiles,
selection: $selection, selection: $selection,
itemWidth: cardWidth, itemWidth: cardWidth,
spacing: 30, spacing: cardSpacing,
onActivate: { $0.activate() }, onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() }, onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true }, onTertiary: { showSettings = true },
@@ -272,6 +295,31 @@ private struct GamepadHostTile: View {
let tile: HomeTile let tile: HomeTile
let size: CGSize 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 { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
@@ -282,38 +330,38 @@ private struct GamepadHostTile: View {
HStack(spacing: 7) { HStack(spacing: 7) {
if tile.isPaired { if tile.isPaired {
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
.font(.system(size: 11, weight: .semibold)) .font(.system(size: Self.statusFont, weight: .semibold))
.foregroundStyle(.white.opacity(0.5)) .foregroundStyle(.white.opacity(0.5))
} }
if tile.isOnline { if tile.isOnline {
Circle() Circle()
.fill(Color.green) .fill(Color.green)
.frame(width: 9, height: 9) .frame(width: Self.pipSide, height: Self.pipSide)
.shadow(color: .green.opacity(0.7), radius: 5) .shadow(color: .green.opacity(0.7), radius: 5)
} }
} }
} }
Spacer(minLength: 0) Spacer(minLength: 0)
Text(tile.title) Text(tile.title)
.font(.geist(23, .bold, relativeTo: .title2)) .font(.geist(Self.titleFont, .bold, relativeTo: .title2))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.7)
Text(tile.subtitle) Text(tile.subtitle)
.font(.geist(13, relativeTo: .caption)) .font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
.lineLimit(1) .lineLimit(1)
.padding(.top, 2) .padding(.top, 2)
} }
.padding(20) .padding(Self.pad)
.frame(width: size.width, height: size.height, alignment: .leading) .frame(width: size.width, height: size.height, alignment: .leading)
// Liquid Glass console tile a brand wash marks a saved host as primary; discovered / // 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. // Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass( .consoleGlass(
RoundedRectangle(cornerRadius: 26, style: .continuous), RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? Color.brand.opacity(0.20) : nil) tint: tile.filled ? Color.brand.opacity(0.20) : nil)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous) RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder( .strokeBorder(
LinearGradient( LinearGradient(
colors: [.white.opacity(0.22), .white.opacity(0.04)], colors: [.white.opacity(0.22), .white.opacity(0.04)],
@@ -324,7 +372,7 @@ private struct GamepadHostTile: View {
} }
private var monogramBadge: some View { private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous) let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
return ZStack { return ZStack {
shape.fill(tile.filled shape.fill(tile.filled
? AnyShapeStyle(LinearGradient( ? AnyShapeStyle(LinearGradient(
@@ -335,15 +383,15 @@ private struct GamepadHostTile: View {
ProgressView().tint(.white) ProgressView().tint(.white)
} else if let icon = tile.icon { } else if let icon = tile.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 24, weight: .semibold)) .font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(Color.brand) .foregroundStyle(Color.brand)
} else { } else {
Text(monogram(tile.title)) Text(monogram(tile.title))
.font(.geistFixed(25, .bold)) .font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? .white : Color.brand) .foregroundStyle(tile.filled ? .white : Color.brand)
} }
} }
.frame(width: 52, height: 52) .frame(width: Self.badgeSide, height: Self.badgeSide)
.overlay { .overlay {
if !tile.filled { if !tile.filled {
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1) shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
@@ -1,8 +1,14 @@
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for // The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a // for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs // a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus // backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the
// cursor, controller polling, haptics, and keeping the focused row scrolled into view. // 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 // 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 // cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
@@ -16,7 +22,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable { struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
let items: [Item] let items: [Item]
@@ -36,6 +42,15 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
@State private var input = GamepadMenuInput(manager: .shared) @State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(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`). /// Authoritative focus cursor (index into `items`).
@State private var cursor = 0 @State private var cursor = 0
/// A short vertical recoil when a move is refused at a list end. /// A short vertical recoil when a move is refused at a list end.
@@ -51,10 +66,23 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
ScrollView(.vertical) { ScrollView(.vertical) {
LazyVStack(spacing: 6) { LazyVStack(spacing: 6) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in 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) row(item, idx == cursor && isActive)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { tap(idx) } .onTapGesture { tap(idx) }
.id(item.id) .id(item.id)
#endif
} }
} }
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -69,6 +97,20 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: 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: cursor)
.sensoryFeedback(.selection, trigger: adjustTick) .sensoryFeedback(.selection, trigger: adjustTick)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
@@ -102,6 +144,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
// MARK: - Input wiring // MARK: - Input wiring
private func wire() { 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 input.onMove = { direction in
switch direction { switch direction {
case .up: step(by: -1) case .up: step(by: -1)
@@ -112,6 +170,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
} }
input.onConfirm = { activate() } input.onConfirm = { activate() }
input.onBack = onBack input.onBack = onBack
#endif
} }
private func step(by delta: Int) { private func step(by delta: Int) {
@@ -123,6 +182,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
haptics.move() haptics.move()
} }
private func adjust(by delta: Int) { private func adjust(by delta: Int) {
guard let onAdjust, cursor >= 0, cursor < items.count else { return } guard let onAdjust, cursor >= 0, cursor < items.count else { return }
if onAdjust(items[cursor], delta) { if onAdjust(items[cursor], delta) {
@@ -165,6 +225,12 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
cursor = min(max(cursor, 0), items.count - 1) cursor = min(max(cursor, 0), items.count - 1)
focusID = items[cursor].id 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) { private func boundaryBump(forward: Bool) {
@@ -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 /// 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. /// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void let wake: (StoredHost) -> Void
/// Experimental game-library browser (gated) the host-card "Browse Library" action. /// Game-library browser (default ON; the Settings toggle opts out) the host-card
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false /// "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. /// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet.
@State private var editTarget: StoredHost? @State private var editTarget: StoredHost?
@@ -48,6 +49,13 @@ struct HomeView: View {
} }
} }
.padding() .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 { if !discoveredUnsaved.isEmpty {
discoveredSection discoveredSection
@@ -67,6 +75,14 @@ struct HomeView: View {
} }
} }
.padding(.top, 24) .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 #endif
} }
} }
@@ -198,6 +214,10 @@ struct HomeView: View {
} }
.padding([.horizontal, .bottom]) .padding([.horizontal, .bottom])
.padding(.top, store.hosts.isEmpty ? 0 : 8) .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 /// Discovered hosts not already saved (see `HostDiscovery.unsaved` shared with the gamepad
@@ -259,7 +279,9 @@ struct HomeView: View {
#if os(macOS) #if os(macOS)
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)] [GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
#elseif os(tvOS) #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 #else
[GridItem(.adaptive(minimum: 280), spacing: 16)] [GridItem(.adaptive(minimum: 280), spacing: 16)]
#endif #endif
@@ -22,8 +22,9 @@ private struct CardMetrics {
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11, CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
padding: 16, spacing: 14, radius: 12) padding: 16, spacing: 14, radius: 12)
#elseif os(tvOS) #elseif os(tvOS)
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14, // 10-foot sizes the 24pt-name tier read like a phone card from the couch.
padding: 18, spacing: 18, radius: 14) CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
padding: 24, spacing: 22, radius: 18)
#else #else
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5, CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
padding: 13, spacing: 12, radius: 10) padding: 13, spacing: 12, radius: 10)
@@ -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 // `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 // 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 // (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
@@ -15,7 +15,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
struct LibraryCoverflowView: View { struct LibraryCoverflowView: View {
@@ -21,9 +21,9 @@ struct LibraryView: View {
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`; /// 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. /// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
@State private var imageSession: URLSession? @State private var imageSession: URLSession?
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
// Gamepad-driven browsing (iOS/iPadOS/macOS) see ContentView's identical gate. tvOS keeps // Gamepad-driven browsing see ContentView's identical gate. With no controller (or the
// its existing plain-grid presentation of this same view unchanged. // setting off) every platform keeps the plain-grid presentation of this same view.
@ObservedObject private var gamepadManager = GamepadManager.shared @ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool { private var gamepadUIActive: Bool {
@@ -69,7 +69,6 @@ struct LibraryView: View {
} else if games.isEmpty { } else if games.isEmpty {
emptyState emptyState
} else { } else {
#if os(iOS) || os(macOS)
if gamepadUIActive { if gamepadUIActive {
LibraryCoverflowView( LibraryCoverflowView(
games: games, imageSession: imageSession, onLaunch: onLaunch, games: games, imageSession: imageSession, onLaunch: onLaunch,
@@ -77,9 +76,6 @@ struct LibraryView: View {
} else { } else {
grid grid
} }
#else
grid
#endif
} }
} }
@@ -38,10 +38,22 @@ struct PunktfunkClientApp: App {
ContentView() ContentView()
#endif #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) .tint(.brand)
#endif
// Geist Sans is the app's typeface. This sets the default for unstyled text and the // 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. // 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)) .font(.geist(17, relativeTo: .body))
#endif
} }
// The Stream menu (Release Mouse Q, Disconnect D, Show/Hide Statistics S // 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 // the cross-client Ctrl+Alt+Shift set) a real menu bar on macOS, hardware-keyboard
@@ -2,6 +2,7 @@
// handshake phase, and the pump-thread main-actor stats relay. // handshake phase, and the pump-thread main-actor stats relay.
import Foundation import Foundation
import os
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
@@ -10,6 +11,15 @@ import SwiftUI
#elseif canImport(UIKit) #elseif canImport(UIKit)
import UIKit import UIKit
#endif #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 /// 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. /// 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 audio: SessionAudio?
private var gamepadCapture: GamepadCapture? private var gamepadCapture: GamepadCapture?
private var gamepadFeedback: GamepadFeedback? 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 } var isBusy: Bool { phase != .idle }
@@ -163,6 +179,14 @@ final class SessionModel: ObservableObject {
let displayHDR: Bool = { let displayHDR: Bool = {
#if os(macOS) #if os(macOS)
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0 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 #else
return UIScreen.main.potentialEDRHeadroom > 1.0 return UIScreen.main.potentialEDRHeadroom > 1.0
#endif #endif
@@ -300,6 +324,10 @@ final class SessionModel: ObservableObject {
// connection is still up); the feedback drain joins off-main like audio. // connection is still up); the feedback drain joins off-main like audio.
gamepadCapture?.stop() gamepadCapture?.stop()
gamepadCapture = nil gamepadCapture = nil
#if os(tvOS)
remotePointer?.stop() // releases any held click while the connection is still up
remotePointer = nil
#endif
let feedback = gamepadFeedback let feedback = gamepadFeedback
gamepadFeedback = nil gamepadFeedback = nil
if let conn = connection { 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 // session's virtual pad is a DualSense). Same trust gate as audio nothing is
// forwarded during the trust prompt. // forwarded during the trust prompt.
let capture = GamepadCapture(connection: conn, manager: .shared) 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() capture.start()
gamepadCapture = capture gamepadCapture = capture
let feedback = GamepadFeedback(connection: conn, manager: .shared) let feedback = GamepadFeedback(connection: conn, manager: .shared)
feedback.start() feedback.start()
gamepadFeedback = feedback 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() { private func startStatsTimer() {
@@ -429,12 +466,32 @@ final class SessionModel: ObservableObject {
} else { } else {
self.decodeValid = false self.decodeValid = false
} }
if let d = self.displayStage.drain() { let displayWindow = self.displayStage.drain()
if let d = displayWindow {
self.displayP50Ms = d.p50Ms self.displayP50Ms = d.p50Ms
self.displayValid = true self.displayValid = true
} else { } else {
self.displayValid = false 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 presentsfps 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. // .common so the HUD keeps updating during window drags / menu tracking.
@@ -26,7 +26,7 @@ struct StreamHUDView: View {
// this card its frame (and, on iOS, its clamped corner) animate to the new size rather // 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. // than cross-fading a whole new card in. Only the inner content switches per tier.
tierContent tierContent
.padding(10) .padding(cardPadding)
.glassBackground(cardShape) .glassBackground(cardShape)
.padding(edgeInset) .padding(edgeInset)
} }
@@ -145,36 +145,43 @@ struct StreamHUDView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
#endif #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) // 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, // 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) #if os(macOS)
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() } Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
#else #elseif os(iOS)
Button("Disconnect") { model.disconnect() } Button("Disconnect") { model.disconnect() }
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
#endif #endif
#endif
} }
} }
// MARK: - Card metrics // MARK: - Card metrics
/// The OUTER gap between the card and the screen edge. (Inner content padding stays a fixed 10.) /// The card's inner content padding. Roomier on tvOS the stat text auto-scales for the
/// On iOS the card hugs a physically rounded display corner, so it sits a little further in and /// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
/// pairs with a concentric corner radius (below); on macOS/tvOS windows the classic 10 reads fine. 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 { private var edgeInset: CGFloat {
#if os(iOS) #if os(iOS)
return 14 return 14
#elseif os(tvOS)
return 24
#else #else
return 10 return 10
#endif #endif
@@ -187,6 +194,8 @@ struct StreamHUDView: View {
private var cardCornerRadius: CGFloat { private var cardCornerRadius: CGFloat {
#if os(iOS) #if os(iOS)
return max(12, DeviceMetrics.displayCornerRadius - edgeInset) return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
#elseif os(tvOS)
return 16 // scales with the roomier padding
#else #else
return 10 return 10
#endif #endif
@@ -1,13 +1,25 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI 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. /// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
struct AcknowledgementsView: View { struct AcknowledgementsView: View {
private var version: String? { private var version: String? {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? 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 { var body: some View {
ScrollView { ScrollView {
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks, // 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. // notice chunks visually continuous; the header block carries its own spacing + bottom pad.
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
Text("punktfunk") Text("Punktfunk")
.font(.geist(22, .bold, relativeTo: .title2)) .font(.geist(Self.titleFont, .bold, relativeTo: .title2))
if let version { if let version {
Text("Version \(version)") Text("Version \(version)")
.font(.geist(12, relativeTo: .caption)) .font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text(Licenses.appLicense) LicenseWall(text: Licenses.appLicense)
.font(.caption.monospaced()) .font(.caption.monospaced())
.modifier(SelectableText())
Divider() Divider()
Text("Bundled font") Text("Bundled font")
.font(.geist(17, .semibold, relativeTo: .headline)) .font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text("punktfunk ships the Geist typeface (Geist Sans), " Text("Punktfunk ships the Geist typeface (Geist Sans), "
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font " + "© The Geist Project Authors / Vercel, used under the SIL Open Font "
+ "License 1.1.") + "License 1.1.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if !Licenses.fontLicense.isEmpty { if !Licenses.fontLicense.isEmpty {
Text(Licenses.fontLicense) LicenseWall(text: Licenses.fontLicense)
.font(.caption2.monospaced()) .font(.caption2.monospaced())
.modifier(SelectableText())
} }
Divider() Divider()
Text("Third-party software") Text("Third-party software")
.font(.geist(17, .semibold, relativeTo: .headline)) .font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text( 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+ " + "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
+ "(dynamically linked, replaceable)." + "(dynamically linked, replaceable)."
) )
.font(.geist(12, relativeTo: .caption)) .font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -62,6 +72,7 @@ struct AcknowledgementsView: View {
.font(.caption2.monospaced()) .font(.caption2.monospaced())
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.modifier(SelectableText()) .modifier(SelectableText())
.modifier(TVFocusable())
} }
} }
.frame(maxWidth: 900, alignment: .leading) .frame(maxWidth: 900, alignment: .leading)
@@ -85,3 +96,40 @@ private struct SelectableText: ViewModifier {
#endif #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
}
}
@@ -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 // 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 // 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 // gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
@@ -13,7 +13,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
struct GamepadSettingsView: View { struct GamepadSettingsView: View {
@@ -34,8 +34,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw @AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue = StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.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.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
@ObservedObject private var gamepads = GamepadManager.shared @ObservedObject private var gamepads = GamepadManager.shared
#if os(iOS) #if os(iOS)
@@ -47,6 +48,9 @@ struct GamepadSettingsView: View {
private let compact = false // no size classes on macOS; the sheet is sized generously private let compact = false // no size classes on macOS; the sheet is sized generously
#endif #endif
@State private var focusID: String? @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 { var body: some View {
GamepadMenuList( GamepadMenuList(
@@ -57,13 +61,13 @@ struct GamepadSettingsView: View {
onBack: { dismiss() } onBack: { dismiss() }
) { row, focused in ) { row, focused in
rowView(row, focused: focused) rowView(row, focused: focused)
.frame(maxWidth: 620) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) { .safeAreaInset(edge: .top, spacing: 0) {
Text("Settings") Text("Settings")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8) .padding(.bottom, compact ? 4 : 8)
@@ -74,7 +78,7 @@ struct GamepadSettingsView: View {
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail) Text(focusedDetail)
.font(.geist(13, relativeTo: .caption)) .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
.lineLimit(2, reservesSpace: true) .lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID) .animation(.smooth(duration: 0.2), value: focusID)
@@ -107,61 +111,78 @@ struct GamepadSettingsView: View {
private var closeButton: some View { private var closeButton: some View {
Button { dismiss() } label: { Button { dismiss() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 34, height: 34) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
.contentShape(Circle()) .contentShape(Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.keyboardShortcut(.cancelAction) #if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Close settings") .accessibilityLabel("Close settings")
} }
// MARK: - Row rendering // MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View { 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 { if let header = row.header {
Text(header) Text(header)
.font(.geist(12, .semibold, relativeTo: .caption)) .font(.geist(m.headerFont, .semibold, relativeTo: .caption))
.tracking(1.4) .tracking(1.4)
.foregroundStyle(.white.opacity(0.45)) .foregroundStyle(.white.opacity(0.45))
.padding(.leading, 16) .padding(.leading, m.rowHPad)
.padding(.top, 14) .padding(.top, 14)
} }
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: row.icon) Image(systemName: row.icon)
.font(.system(size: 17)) .font(.system(size: m.iconFont))
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55)) .foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
.frame(width: 28) .frame(width: m.iconWidth)
Text(row.label) Text(row.label)
.font(.geist(16, .semibold, relativeTo: .body)) .font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 12) Spacer(minLength: 12)
HStack(spacing: 9) { HStack(spacing: 9) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0)) .foregroundStyle(.white.opacity(focused ? 0.6 : 0))
// 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) Text(row.value)
.font(.geist(15, .medium, relativeTo: .callout)) .font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? .white : .white.opacity(0.6)) .foregroundStyle(focused ? .white : .white.opacity(0.6))
.lineLimit(1) .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") Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0)) .foregroundStyle(.white.opacity(focused ? 0.6 : 0))
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, m.rowHPad)
.padding(.vertical, 13) .padding(.vertical, m.rowVPad)
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
.consoleGlass( .consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous), RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? Color.brand.opacity(0.30) : nil, tint: focused ? Color.brand.opacity(0.30) : nil,
interactive: focused) interactive: focused)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous) RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) .strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
} }
.scaleEffect(focused ? 1.0 : 0.98) .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 /// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
/// (never on state captured at wire time). /// (never on state captured at wire time).
private func adjust(id: String, by delta: Int) -> Bool { 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) { private func activate(id: String) {
lastAdjustDelta = 1 // A always cycles forward
rows.first { $0.id == id }?.activate() 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 " detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
+ "hardware decode.", + "hardware decode.",
value: $enable444), 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( choiceRow(
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels", id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
@@ -287,8 +316,7 @@ struct GamepadSettingsView: View {
) { hudPlacement = $0 }, ) { hudPlacement = $0 },
toggleRow( toggleRow(
id: "library", icon: "square.grid.2x2", label: "Game library", id: "library", icon: "square.grid.2x2", label: "Game library",
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) " detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
+ "(experimental).",
value: $libraryEnabled), value: $libraryEnabled),
toggleRow( toggleRow(
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI", id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
@@ -37,6 +37,30 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] = static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) } 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. /// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value.
static let statsVerbosities: [(label: String, tag: String)] = static let statsVerbosities: [(label: String, tag: String)] =
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) } StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
@@ -105,8 +129,8 @@ enum SettingsOptions {
return options return options
} }
#if os(iOS) || os(macOS) // MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the
// MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list) // touch/remote tvOS SettingsView builds its own preset list)
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`. /// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [ static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
@@ -124,8 +148,8 @@ enum SettingsOptions {
@MainActor @MainActor
static func resolutionModes() -> [(name: String, w: Int, h: Int)] { static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
var native: [(name: String, w: Int, h: Int)] = [] var native: [(name: String, w: Int, h: Int)] = []
#if os(iOS) #if os(iOS) || os(tvOS)
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
native = [("This device", native = [("This device",
Int(max(bounds.width, bounds.height)), Int(max(bounds.width, bounds.height)),
Int(min(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. /// the screen can't show), plus any stored custom value so it stays selectable.
@MainActor @MainActor
static func refreshRates(including current: Int) -> [Int] { static func refreshRates(including current: Int) -> [Int] {
#if os(iOS) #if os(iOS) || os(tvOS)
let maxHz = UIScreen.main.maximumFramesPerSecond let maxHz = UIScreen.main.maximumFramesPerSecond
#else #else
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60 let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
@@ -155,5 +179,4 @@ enum SettingsOptions {
if !rates.contains(current) { rates.append(current) } if !rates.contains(current) { rates.append(current) }
return rates.sorted() return rates.sorted()
} }
#endif
} }
@@ -24,7 +24,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.compositor) var compositor = 0 @AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) var presenter = "stage2" @AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
#if os(macOS) #if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false @AppStorage(DefaultsKey.vsync) var vsync = false
#endif #endif
@@ -33,7 +33,7 @@ struct SettingsView: View {
#endif #endif
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true @AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
@AppStorage(DefaultsKey.enable444) var enable444 = 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.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true @AppStorage(DefaultsKey.micEnabled) var micEnabled = true
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2 @AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
@@ -43,9 +43,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue @AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared @ObservedObject var gamepads = GamepadManager.shared
#if !os(tvOS)
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
#endif
#if DEBUG && !os(tvOS) #if DEBUG && !os(tvOS)
@State var showControllerTest = false @State var showControllerTest = false
#endif #endif
@@ -284,19 +282,6 @@ struct SettingsView: View {
("4K @ 60", "3840x2160x60"), ("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<String> { private var modeTag: Binding<String> {
Binding( Binding(
get: { "\(width)x\(height)x\(hz)" }, get: { "\(width)x\(height)x\(hz)" },
@@ -313,6 +298,12 @@ struct SettingsView: View {
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" }) 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<String> {
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
}
private var tvBody: some View { private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)" let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds let bounds = UIScreen.main.nativeBounds
@@ -338,7 +329,7 @@ struct SettingsView: View {
selection: $audioChannels) selection: $audioChannels)
if bitrateKbps > 1_000_000 { if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption)) .font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange) .foregroundStyle(.orange)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
@@ -347,7 +338,7 @@ struct SettingsView: View {
selection: $compositor) selection: $compositor)
TVSelectionRow( TVSelectionRow(
title: "Presenter", title: "Presenter",
options: Self.presenterOptions, options: SettingsOptions.presenters,
selection: $presenter) selection: $presenter)
TVSelectionRow( TVSelectionRow(
title: "10-bit HDR", title: "10-bit HDR",
@@ -355,7 +346,7 @@ struct SettingsView: View {
Text("The host creates a virtual output at exactly this mode — native " Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor " + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
+ "is honored only if available on the host.") + "is honored only if available on the host.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 8) .padding(.top, 8)
@@ -375,8 +366,11 @@ struct SettingsView: View {
TVSelectionRow( TVSelectionRow(
title: "Controller type", options: SettingsOptions.padTypes, title: "Controller type", options: SettingsOptions.padTypes,
selection: $gamepadType) selection: $gamepadType)
TVSelectionRow(
title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
Text(Self.controllersFooter) Text(Self.controllersFooter)
.font(.geist(12, relativeTo: .caption)) .font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 8) .padding(.top, 8)
@@ -82,20 +82,36 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
var interactive = false var interactive = false
func body(content: Content) -> some View { 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) content.glassEffect(glass, in: shape)
} else { } else {
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) } 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 { private var glass: Glass {
var g: Glass = .regular var g: Glass = .regular
if let tint { g = g.tint(tint) } if let tint { g = g.tint(tint) }
if interactive { g = g.interactive() } if interactive { g = g.interactive() }
return g return g
} }
#endif
} }
extension View { extension View {
@@ -48,7 +48,7 @@ struct PairSheet: View {
+ "(http://<host>:3000 → Pairing). " + "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison " + "Pairing verifies both sides at once — no fingerprint comparison "
+ "needed.") + "needed.")
.font(.geist(16, relativeTo: .callout)) .font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
TVFieldRow( TVFieldRow(
@@ -59,7 +59,7 @@ struct PairSheet: View {
) { editing = .clientName } ) { editing = .clientName }
if let errorText { if let errorText {
Text(errorText) Text(errorText)
.font(.geist(16, relativeTo: .callout)) .font(.geist(22, relativeTo: .callout))
.foregroundStyle(.red) .foregroundStyle(.red)
} }
HStack(spacing: 32) { HStack(spacing: 32) {
@@ -263,19 +263,24 @@ public final class SessionAudio {
defer { drainDone.signal() } defer { drainDone.signal() }
// Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is // 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. // 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? let pcm: PunktfunkConnection.AudioPCM?
do { do {
pcm = try connection.nextAudioPcm(timeoutMs: 100) pcm = try connection.nextAudioPcm(timeoutMs: 100)
} catch { } 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 pcm.samples.withUnsafeBufferPointer { p in
if let base = p.baseAddress { if let base = p.baseAddress {
ring.write(base, count: pcm.frameCount * pcm.channels) ring.write(base, count: pcm.frameCount * pcm.channels)
} }
} }
return true
}
} }
} }
thread.name = "punktfunk-audio" thread.name = "punktfunk-audio"
@@ -48,6 +48,23 @@ public final class GamepadCapture {
/// Motion forwarding floor: 4 ms between samples ( 250 Hz, the DualSense's own rate). /// Motion forwarding floor: 4 ms between samples ( 250 Hz, the DualSense's own rate).
private static let motionIntervalNs: UInt64 = 4_000_000 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) { public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection self.connection = connection
self.manager = manager self.manager = manager
@@ -165,6 +182,7 @@ public final class GamepadCapture {
private func sync(_ g: GCExtendedGamepad) { private func sync(_ g: GCExtendedGamepad) {
guard !suspended else { return } guard !suspended else { return }
let newButtons = Self.buttonMask(g) let newButtons = Self.buttonMask(g)
updateEscapeChord(newButtons)
let changed = newButtons ^ buttons let changed = newButtons ^ buttons
if changed != 0 { if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 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 /// 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. /// 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() { private func releaseAll() {
chordTimer?.invalidate()
chordTimer = nil
for bit in GamepadWire.allButtons where buttons & bit != 0 { for bit in GamepadWire.allButtons where buttons & bit != 0 {
connection.send(.gamepadButton(bit, down: false, pad: 0)) connection.send(.gamepadButton(bit, down: false, pad: 0))
} }
@@ -74,7 +74,11 @@ public final class GamepadFeedback {
// session a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and // 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 rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
let thread = Thread { [connection, flag, drainDone, weak self] in 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 { do {
// Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds // 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 // 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) self?.render(ev)
burst += 1 burst += 1
} }
return true
} catch { } 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 // ~8 ms poll cadence (125 Hz), slept OUTSIDE the feedback lock low rumble/HID
// latency without holding the lock the HDR-meta drain needs. // 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() drainDone.signal()
} }
@@ -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<UInt32> = []
/// 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
@@ -13,14 +13,14 @@ public enum Licenses {
return text 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 { public static var appLicense: String {
let mit = resource("LICENSE-MIT") let mit = resource("LICENSE-MIT")
let apache = resource("LICENSE-APACHE") let apache = resource("LICENSE-APACHE")
if mit.isEmpty && apache.isEmpty { 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"
+ mit + mit
+ "\n\n============================== Apache-2.0 ==============================\n\n" + "\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 /// 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"; /// 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. /// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
public static let thirdPartyNoticesChunks: [String] = { public static let thirdPartyNoticesChunks: [String] = chunked(thirdPartyNotices)
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
let chunkSize = 200 /// Lines per chunk: tvOS reads much smaller chunks focus is how tvOS scrolls, and each
return stride(from: 0, to: lines.count, by: chunkSize).map { start in /// chunk is one focus stop, so a 200-line chunk (~5 screens tall there) would skip most of
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n") /// itself per step; ~24 lines two thirds of a screen reads like a page turn. Elsewhere the
/// only constraint is the text-render height limit, so chunks stay big.
private static var chunkLines: Int {
#if os(tvOS)
24
#else
200
#endif
}
/// `text` split at line boundaries into render/focus-sized chunks (joined with "\n"; the
/// inter-chunk break is the caller's stack-row boundary, so no text is lost). tvOS pages
/// focus through these every license wall on the Acknowledgements screen renders this way.
public static func chunked(_ text: String) -> [String] {
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
return stride(from: 0, to: lines.count, by: chunkLines).map { start in
lines[start..<min(start + chunkLines, lines.count)].joined(separator: "\n")
}
} }
}()
} }
@@ -10,6 +10,9 @@
import AVFoundation import AVFoundation
import Foundation import Foundation
import QuartzCore import QuartzCore
#if os(tvOS)
import UIKit
#endif
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or /// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or
/// presenter directly makes a `owner link owner` cycle that only `invalidate()` breaks if a /// presenter directly makes a `owner link owner` cycle that only `invalidate()` breaks if a
@@ -34,16 +37,31 @@ enum PresenterChoice: Equatable {
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first, /// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var) /// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG /// falls back to the platform default. `allowStage1` is false in release builds, where a
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback. /// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
/// freeze-prone fallback.
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice { static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw { switch raw {
case "stage1": return allowStage1 ? .stage1 : .stage2 case "stage1": return allowStage1 ? .stage1 : platformDefault
case "stage2": return .stage2
case "stage3": return .stage3 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 { final class SessionPresenter {
@@ -179,6 +197,13 @@ final class SessionPresenter {
stage2?.setDrawableTarget(CGSize( stage2?.setDrawableTarget(CGSize(
width: (fit.width * contentsScale).rounded(), width: (fit.width * contentsScale).rounded(),
height: (fit.height * 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 /// Stop the active pump/pipeline ( one poll timeout; stage-2 joins its pump) and detach the
@@ -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 // 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. // resolution-ceiling miss). End cleanly instead of looping on a black screen.
var decodeFailRun = 0 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 { do {
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the // Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
// decoder conceals the reference-missing deltas often WITHOUT an error callback // 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) { if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
presenter.setHdrMeta(meta) 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) onFrame?(au)
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) { if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
format = f // refreshed on every IDR (mode changes included) format = f // refreshed on every IDR (mode changes included)
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete 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) { if decoder.decode(au: au, format: f) {
decodeFailRun = 0 decodeFailRun = 0
} else { } else {
@@ -397,12 +402,14 @@ public final class Stage2Pipeline {
// recovers within a GOP) 4:4:4 isn't decodable here; end the session. // recovers within a GOP) 4:4:4 isn't decodable here; end the session.
if connection.isChroma444, decodeFailRun >= 180 { if connection.isChroma444, decodeFailRun >= 180 {
if !token.isStopped { onSessionEnd?() } if !token.isStopped { onSessionEnd?() }
break return false
} }
} }
return true
} catch { } catch {
if !token.isStopped { onSessionEnd?() } 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 gate: PresentGate? = pacing == .glass ? PresentGate() : nil
let renderThread = Thread { let renderThread = Thread {
defer { renderStopped.signal() } 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 { if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
debugStats?.flushIfDue(ring: ring, gate: gate) 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 // 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 // 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()) { if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
debugStats?.gatedWake() debugStats?.gatedWake()
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
continue return
} }
guard !token.isStopped, let frame = ring.take() else { guard !token.isStopped, let frame = ring.take() else {
gate?.release() // armed but nothing to render don't hold the gate stale gate?.release() // armed but nothing to render don't hold the gate stale
debugStats?.emptyWake() debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
continue return
} }
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link // 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. // immediate see VsyncClock). OFF: flip as soon as the GPU finishes.
@@ -488,7 +499,7 @@ public final class Stage2Pipeline {
ring.putBack(frame) ring.putBack(frame)
} }
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
} } }
} }
renderThread.name = "punktfunk-stage2-render" renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive renderThread.qualityOfService = .userInteractive
@@ -512,6 +523,13 @@ public final class Stage2Pipeline {
presenter.setDrawableTarget(size) 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 /// 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 /// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
/// (the stop is permanent). /// (the stop is permanent).
@@ -47,7 +47,12 @@ final class StreamPump {
var awaitingIDR = false var awaitingIDR = false
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log) var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
var wasFailed = false 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 { do {
// Loss recovery (the primary path). Under the host's infinite GOP the only // Loss recovery (the primary path). Under the host's infinite GOP the only
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs // recovery keyframe is one we request. The reassembler drops unrecoverable AUs
@@ -69,7 +74,7 @@ final class StreamPump {
} }
if awaitingIDR { recovery.request() } 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) onFrame?(au)
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data) let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
if let f = idrFormat { if let f = idrFormat {
@@ -97,13 +102,15 @@ final class StreamPump {
guard let f = format, guard let f = format,
let sample = connection.videoCodec.sampleBuffer(au: au, format: f), let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
!token.isStopped // don't enqueue a stale frame after a restart !token.isStopped // don't enqueue a stale frame after a restart
else { continue } else { return true }
layer.enqueue(sample) layer.enqueue(sample)
return true
} catch { } catch {
if !token.isStopped { if !token.isStopped {
onSessionEnd?() onSessionEnd?()
} }
break // session closed return false // session closed
}
} }
} }
} }
@@ -36,6 +36,9 @@ import PunktfunkCore
import SwiftUI import SwiftUI
import UIKit import UIKit
import os 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 /// 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 /// 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? public private(set) var connection: PunktfunkConnection?
private var observers: [NSObjectProtocol] = [] private var observers: [NSObjectProtocol] = []
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2 /// 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 /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback. /// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter() 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) #if os(iOS)
private var inputCapture: InputCapture? private var inputCapture: InputCapture?
fileprivate var captured = false fileprivate var captured = false
@@ -157,6 +178,12 @@ public final class StreamViewController: UIViewController {
public override func loadView() { public override func loadView() {
view = StreamLayerUIView() 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. // 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 // 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 // viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
@@ -230,6 +257,18 @@ public final class StreamViewController: UIViewController {
} }
#endif #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( func start(
connection: PunktfunkConnection, connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
@@ -342,6 +381,19 @@ public final class StreamViewController: UIViewController {
setCaptured(true) // entering a session is the deliberate "capture me" moment setCaptured(true) // entering a session is the deliberate "capture me" moment
} }
#endif #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() { func stop() {
@@ -360,6 +412,12 @@ public final class StreamViewController: UIViewController {
streamView.onScroll = nil streamView.onScroll = nil
streamView.currentHostMode = nil streamView.currentHostMode = nil
#endif #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() presenter.stop()
connection = nil connection = nil
} }
@@ -367,8 +425,50 @@ public final class StreamViewController: UIViewController {
public override func viewDidLayoutSubviews() { public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
layoutMetalLayer() 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 /// 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; /// 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 /// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which