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
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:
@@ -10,6 +10,9 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import QuartzCore
|
||||
#if os(tvOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// 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
|
||||
@@ -34,16 +37,31 @@ enum PresenterChoice: Equatable {
|
||||
|
||||
/// 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)
|
||||
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG
|
||||
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback.
|
||||
/// falls back to the platform default. `allowStage1` is false in release builds, where a
|
||||
/// 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 {
|
||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||
switch raw {
|
||||
case "stage1": return allowStage1 ? .stage1 : .stage2
|
||||
case "stage1": return allowStage1 ? .stage1 : platformDefault
|
||||
case "stage2": return .stage2
|
||||
case "stage3": return .stage3
|
||||
default: return .stage2
|
||||
default: return platformDefault
|
||||
}
|
||||
}
|
||||
|
||||
/// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction —
|
||||
/// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue
|
||||
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
|
||||
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
|
||||
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
|
||||
static var platformDefault: PresenterChoice {
|
||||
#if os(tvOS)
|
||||
.stage3
|
||||
#else
|
||||
.stage2
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
final class SessionPresenter {
|
||||
@@ -179,6 +197,13 @@ final class SessionPresenter {
|
||||
stage2?.setDrawableTarget(CGSize(
|
||||
width: (fit.width * contentsScale).rounded(),
|
||||
height: (fit.height * contentsScale).rounded()))
|
||||
#if os(tvOS)
|
||||
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
|
||||
// HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR
|
||||
// frames flip to PQ passthrough. The stream view also re-layouts on mode-switch/screen-
|
||||
// mode notifications, so a mid-session switch reaches here without a bounds change.
|
||||
stage2?.setDisplayHeadroom(UIScreen.main.currentEDRHeadroom)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||
|
||||
@@ -358,7 +358,12 @@ public final class Stage2Pipeline {
|
||||
// decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a
|
||||
// resolution-ceiling miss). End cleanly instead of looping on a black screen.
|
||||
var decodeFailRun = 0
|
||||
while !token.isStopped {
|
||||
// Every iteration drains its own autorelease pool: this thread has no runloop, so
|
||||
// autoreleased VT/CM temporaries would otherwise accumulate until session end.
|
||||
// `false` = session over — exit the loop (the closure can't `break` across itself).
|
||||
var alive = true
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||
@@ -378,13 +383,13 @@ public final class Stage2Pipeline {
|
||||
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||
presenter.setHdrMeta(meta)
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
onFrame?(au)
|
||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
guard let f = format, !token.isStopped else { continue }
|
||||
guard let f = format, !token.isStopped else { return true }
|
||||
if decoder.decode(au: au, format: f) {
|
||||
decodeFailRun = 0
|
||||
} else {
|
||||
@@ -397,12 +402,14 @@ public final class Stage2Pipeline {
|
||||
// recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session.
|
||||
if connection.isChroma444, decodeFailRun >= 180 {
|
||||
if !token.isStopped { onSessionEnd?() }
|
||||
break
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
if !token.isStopped { onSessionEnd?() }
|
||||
break // session closed
|
||||
return false // session closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,10 +442,14 @@ public final class Stage2Pipeline {
|
||||
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
||||
let renderThread = Thread {
|
||||
defer { renderStopped.signal() }
|
||||
while !token.isStopped {
|
||||
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
||||
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
||||
// without a per-iteration pool every presented frame's drawable object (plus its
|
||||
// texture-descriptor/array retinue, ~2 MB/min at 120 fps) piles up until session end.
|
||||
while !token.isStopped { autoreleasepool {
|
||||
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
continue
|
||||
return
|
||||
}
|
||||
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
||||
// keep coalescing there (newest wins, the intended drop point) and the presented
|
||||
@@ -447,13 +458,13 @@ public final class Stage2Pipeline {
|
||||
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
|
||||
debugStats?.gatedWake()
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
continue
|
||||
return
|
||||
}
|
||||
guard !token.isStopped, let frame = ring.take() else {
|
||||
gate?.release() // armed but nothing to render — don't hold the gate stale
|
||||
debugStats?.emptyWake()
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
continue
|
||||
return
|
||||
}
|
||||
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
||||
// immediate — see VsyncClock). OFF: flip as soon as the GPU finishes.
|
||||
@@ -488,7 +499,7 @@ public final class Stage2Pipeline {
|
||||
ring.putBack(frame)
|
||||
}
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
}
|
||||
} }
|
||||
}
|
||||
renderThread.name = "punktfunk-stage2-render"
|
||||
renderThread.qualityOfService = .userInteractive
|
||||
@@ -512,6 +523,13 @@ public final class Stage2Pipeline {
|
||||
presenter.setDrawableTarget(size)
|
||||
}
|
||||
|
||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
|
||||
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
||||
public func setDisplayHeadroom(_ headroom: CGFloat) {
|
||||
presenter.setDisplayHeadroom(headroom)
|
||||
}
|
||||
|
||||
/// Stop the pump + render thread (≤ one poll timeout each) and drop the decode session. MAIN
|
||||
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
|
||||
/// (the stop is permanent).
|
||||
|
||||
@@ -47,7 +47,12 @@ final class StreamPump {
|
||||
var awaitingIDR = false
|
||||
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
||||
var wasFailed = false
|
||||
while !token.isStopped {
|
||||
// Every iteration drains its own autorelease pool: this thread has no runloop, so
|
||||
// autoreleased CM/layer temporaries would otherwise accumulate until session end.
|
||||
// `false` = session over — exit the loop (the closure can't `break` across itself).
|
||||
var alive = true
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||
@@ -69,7 +74,7 @@ final class StreamPump {
|
||||
}
|
||||
if awaitingIDR { recovery.request() }
|
||||
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
onFrame?(au)
|
||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||
if let f = idrFormat {
|
||||
@@ -97,13 +102,15 @@ final class StreamPump {
|
||||
guard let f = format,
|
||||
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
||||
!token.isStopped // don't enqueue a stale frame after a restart
|
||||
else { continue }
|
||||
else { return true }
|
||||
layer.enqueue(sample)
|
||||
return true
|
||||
} catch {
|
||||
if !token.isStopped {
|
||||
onSessionEnd?()
|
||||
}
|
||||
break // session closed
|
||||
return false // session closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user