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
@@ -1,13 +1,25 @@
import PunktfunkKit
import SwiftUI
/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the
/// Open-source acknowledgements: Punktfunk's own license (MIT OR Apache-2.0) followed by the
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
struct AcknowledgementsView: View {
private var version: String? {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
}
// TV-legible sizes for the explicitly-sized text; the in-hand sizes elsewhere. (The license
// walls use relative system styles, which already scale per platform.)
#if os(tvOS)
private static let titleFont: CGFloat = 36
private static let headlineFont: CGFloat = 26
private static let captionFont: CGFloat = 20
#else
private static let titleFont: CGFloat = 22
private static let headlineFont: CGFloat = 17
private static let captionFont: CGFloat = 12
#endif
var body: some View {
ScrollView {
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
@@ -16,42 +28,40 @@ struct AcknowledgementsView: View {
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
LazyVStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 18) {
Text("punktfunk")
.font(.geist(22, .bold, relativeTo: .title2))
Text("Punktfunk")
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
if let version {
Text("Version \(version)")
.font(.geist(12, relativeTo: .caption))
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
}
Text(Licenses.appLicense)
LicenseWall(text: Licenses.appLicense)
.font(.caption.monospaced())
.modifier(SelectableText())
Divider()
Text("Bundled font")
.font(.geist(17, .semibold, relativeTo: .headline))
Text("punktfunk ships the Geist typeface (Geist Sans), "
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text("Punktfunk ships the Geist typeface (Geist Sans), "
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
+ "License 1.1.")
.font(.geist(12, relativeTo: .caption))
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
if !Licenses.fontLicense.isEmpty {
Text(Licenses.fontLicense)
LicenseWall(text: Licenses.fontLicense)
.font(.caption2.monospaced())
.modifier(SelectableText())
}
Divider()
Text("Third-party software")
.font(.geist(17, .semibold, relativeTo: .headline))
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text(
"punktfunk uses the open-source components below, each under its own license. "
"Punktfunk uses the open-source components below, each under its own license. "
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
+ "(dynamically linked, replaceable)."
)
.font(.geist(12, relativeTo: .caption))
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -62,6 +72,7 @@ struct AcknowledgementsView: View {
.font(.caption2.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(SelectableText())
.modifier(TVFocusable())
}
}
.frame(maxWidth: 900, alignment: .leading)
@@ -85,3 +96,40 @@ private struct SelectableText: ViewModifier {
#endif
}
}
/// Focus IS scrolling on tvOS: with nothing focusable in this pushed screen the license wall
/// couldn't move at all, and a Menu press had nothing inside the NavigationStack to route
/// through it suspended the whole app instead of popping. Plain (non-interactive) focusability
/// on every license/notice chunk fixes both; a chunk is sized to about two thirds of a screen
/// (see Licenses.chunked), so each focus step reads as a page turn. The chunks must be SMALL
/// focus stops all the way down one tall focusable block would strand focus at its top and the
/// next stop could sit past the LazyVStack's instantiation window.
private struct TVFocusable: ViewModifier {
func body(content: Content) -> some View {
#if os(tvOS)
content.focusable()
#else
content
#endif
}
}
/// One license wall: a single selectable Text on touch/desktop; on tvOS, focus-page-sized
/// chunks (see TVFocusable). The caller's `.font` cascades into either form.
private struct LicenseWall: View {
let text: String
var body: some View {
#if os(tvOS)
let chunks = Licenses.chunked(text)
ForEach(chunks.indices, id: \.self) { i in
Text(chunks[i])
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(TVFocusable())
}
#else
Text(text)
.modifier(SelectableText())
#endif
}
}