feat(apple): the statistics overlay is reachable on tvOS
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m5s
ci / rust (pull_request) Successful in 6m27s
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m5s
ci / rust (pull_request) Successful in 6m27s
An Apple TV session had no way to the stats overlay at all. Every other client cycles it in-stream — Ctrl+Alt+Shift+S on the desktops, a three-finger tap on touch — and tvOS has neither a keyboard nor a screen to tap, so the only route was Settings before connecting (or a profile). The docs' own "cycle with" table simply had no row for it. Two surfaces, because an Apple TV may have a controller in the room or only the remote: - Select + X on a controller, cycling one tier per completion. Built like Android's mic chord (Select + Y) and deliberately disjoint from the escape chord — X is none of its four buttons, so reaching for one can never trip the other. Read off the wire mask like the escape chord, so a Select the hold-Select gesture has turned into a guide can't cycle the overlay on its way past. Available on every Apple platform: a controller in both hands is exactly the case the keyboard combo and the three-finger tap can't serve. - Hold Play/Pause on the Siri Remote. Its right-click is therefore deferred until the press resolves — a tap still right-clicks, delivered on release with the release trailing by TAP_PRESS — because a right button held for half a second is a context menu on every desktop this streams. A non-forwarding slot now claims the stats chord's elements too, alongside the escape chord's: on tvOS an unclaimed button's press stays the system's and the chord would silently never complete. Tests pin both chords' masks against their GameController alias lists, that the two overlap only on Select, and that the claim list covers both without duplicates — the failure mode is nothing happening, with nothing logged.
This commit is contained in:
@@ -906,6 +906,7 @@ struct ContentView: View {
|
||||
private var shortcutHintText: String {
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
|
||||
@@ -112,6 +112,24 @@ public final class GamepadCapture {
|
||||
static let escapeChordElements = [
|
||||
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
|
||||
]
|
||||
/// The stats-overlay chord: Select + X, one tier per completion (off → compact → normal →
|
||||
/// detailed → off). It exists because a controller in both hands has no other way to the
|
||||
/// numbers — the ⌃⌥⇧S combo needs a keyboard and the three-finger tap needs a free screen —
|
||||
/// and on tvOS there is no other way AT ALL, which is what this fixes.
|
||||
///
|
||||
/// Built like Android's mic chord (`GamepadRouter.MIC_CHORD`, Select + Y) and deliberately
|
||||
/// not overlapping `escapeChord`: X is none of its four buttons, so no way of reaching the
|
||||
/// exit chord passes through this one on the way, and vice versa. Select is a menu button
|
||||
/// rather than a twitch action, which keeps the pair out of real play. Y is left free so the
|
||||
/// mic chord can be ported onto it later without moving this one.
|
||||
static let statsChord: UInt32 = GamepadWire.back | GamepadWire.x
|
||||
/// `statsChord`'s elements by GameController alias — same mirror-the-mask rule (and same
|
||||
/// invisible failure) as `escapeChordElements`; the same test pins both.
|
||||
static let statsChordElements = [GCInputButtonOptions, GCInputButtonX]
|
||||
/// Every element some chord reads — what a NON-forwarding slot claims (see `openSlot`). The
|
||||
/// escape chord's four plus the stats chord's X; Select is shared, so it appears once.
|
||||
static let chordElements: [String] =
|
||||
escapeChordElements + statsChordElements.filter { !escapeChordElements.contains($0) }
|
||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
||||
private static let disconnectHold: TimeInterval = 1.5
|
||||
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes
|
||||
@@ -288,14 +306,15 @@ public final class GamepadCapture {
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||
//
|
||||
// With forwarding OFF none of that applies — no press reaches the host, so taking the
|
||||
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
|
||||
// is still read off this slot, and on tvOS it is the only controller way out of a
|
||||
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
|
||||
// its gesture attached on tvOS and the press is the system's — the chord would never
|
||||
// complete and the session would have no controller exit at all.)
|
||||
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the CHORDS are
|
||||
// still read off this slot — on tvOS the escape chord is the only controller way out of
|
||||
// a stream, and the stats chord the only way to the overlay — so their own elements keep
|
||||
// their claim. (Menu especially: leave its gesture attached on tvOS and the press is the
|
||||
// system's — the chord would never complete and the session would have no controller
|
||||
// exit at all.)
|
||||
let claimed = forwarding
|
||||
? Array(c.physicalInputProfile.elements.values)
|
||||
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
|
||||
: Self.chordElements.compactMap { c.physicalInputProfile.elements[$0] }
|
||||
for element in claimed {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
@@ -437,10 +456,24 @@ public final class GamepadCapture {
|
||||
let newButtons = raw | (slot.buttons & GamepadWire.guide)
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
let was = slot.buttons
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = newButtons
|
||||
// The stats chord, edge-triggered on the press that COMPLETES it: one cycle per
|
||||
// chord rather than one per press, since a third button pressed on top finds the
|
||||
// mask already complete and can't re-fire it. Read off the wire mask like the escape
|
||||
// chord, which means a Select the hold-Select gesture has turned into a guide is not
|
||||
// in it — a guide hold can't cycle the overlay on its way past. The buttons still
|
||||
// forward (the chord is a local overlay change, not an input the host must not see).
|
||||
if was & Self.statsChord != Self.statsChord,
|
||||
newButtons & Self.statsChord == Self.statsChord {
|
||||
// Straight to the shared tier default, like TouchMouse's three-finger tap: every
|
||||
// reader (the HUD, the Settings pickers, the live session) observes it through
|
||||
// @AppStorage, so no wiring back to the app is needed.
|
||||
StatsVerbosity.cycle()
|
||||
}
|
||||
}
|
||||
let newAxes: [Int32] = [
|
||||
Int32(g.leftThumbstick.xAxis.value * 32767),
|
||||
|
||||
@@ -34,10 +34,26 @@ public final class SiriRemotePointer {
|
||||
private var heldButtons: Set<UInt32> = []
|
||||
/// When Back/Menu went down; a release after `disconnectHold` fires the exit.
|
||||
private var menuDownAt: Date?
|
||||
/// Counts a held Play/Pause down to `statsHold`; nil when the button is up or already
|
||||
/// resolved. See `playPauseChanged`.
|
||||
private var playPauseTimer: Timer?
|
||||
/// The held Play/Pause has already been spent on a stats cycle, so its release must not also
|
||||
/// right-click.
|
||||
private var statsHoldFired = false
|
||||
/// Trails a delivered right-click tap by `tapPress` to release it — see `deliverRightClick`.
|
||||
private var rightReleaseTimer: Timer?
|
||||
|
||||
/// 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
|
||||
/// Hold Play/Pause this long to cycle the stats overlay instead of right-clicking. It is the
|
||||
/// remote's only spare button, and on an Apple TV with no controller in the room this is the
|
||||
/// ONLY route to the numbers (⌃⌥⇧S wants a keyboard, the three-finger tap a touchscreen).
|
||||
/// Shorter than `disconnectHold`: nothing destructive rides on it.
|
||||
private static let statsHold: TimeInterval = 0.5
|
||||
/// pf-client-core's `TAP_PRESS`, borrowed for the deferred right-click: its release trails
|
||||
/// the press by this much, so the two transitions can't fold into nothing downstream.
|
||||
private static let tapPress: TimeInterval = 0.05
|
||||
/// 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
|
||||
@@ -95,6 +111,9 @@ public final class SiriRemotePointer {
|
||||
old.buttonX.pressedChangedHandler = nil
|
||||
old.buttonMenu.pressedChangedHandler = nil
|
||||
}
|
||||
// Timers first, then the lift: a tap whose release is still owed is held state, so
|
||||
// `releaseHeld` below is what sends its button-up.
|
||||
cancelPlayPause()
|
||||
releaseHeld()
|
||||
lastTouch = nil
|
||||
menuDownAt = nil
|
||||
@@ -109,12 +128,13 @@ public final class SiriRemotePointer {
|
||||
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).
|
||||
// Surface click = left button; Play/Pause = right (the remote's only spare face button),
|
||||
// or — held — the stats-overlay cycle. See `playPauseChanged`.
|
||||
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) }
|
||||
MainActor.assumeIsolated { self?.playPauseChanged(pressed: pressed) }
|
||||
}
|
||||
micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) }
|
||||
@@ -149,6 +169,76 @@ public final class SiriRemotePointer {
|
||||
connection.send(.mouseButton(button, down: down))
|
||||
}
|
||||
|
||||
/// Play/Pause: a TAP right-clicks, a HOLD (`statsHold`) cycles the stats overlay instead.
|
||||
///
|
||||
/// The right button is therefore DEFERRED until the press resolves, rather than going down on
|
||||
/// contact: once the host has seen a button-down there is no taking it back, and a right
|
||||
/// button held for half a second is a context menu on every desktop this streams. The shape
|
||||
/// is the hold-Select gesture's (`GamepadCapture.gestureFiltered`) — suppress, then deliver a
|
||||
/// tap on release or the gesture past the threshold — so the two behave alike.
|
||||
private func playPauseChanged(pressed: Bool) {
|
||||
if pressed {
|
||||
statsHoldFired = false
|
||||
let timer = Timer(timeInterval: Self.statsHold, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in self?.statsHoldElapsed() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
playPauseTimer?.invalidate()
|
||||
playPauseTimer = timer
|
||||
return
|
||||
}
|
||||
playPauseTimer?.invalidate()
|
||||
playPauseTimer = nil
|
||||
// The hold already spent this press on a cycle — its release clicks nothing.
|
||||
guard !statsHoldFired else {
|
||||
statsHoldFired = false
|
||||
return
|
||||
}
|
||||
deliverRightClick()
|
||||
}
|
||||
|
||||
/// The threshold passed with Play/Pause still down → cycle the overlay and consume the press.
|
||||
/// Writes the shared `statsVerbosity` default every reader observes through @AppStorage — the
|
||||
/// same cycle as ⌃⌥⇧S, the three-finger tap and the controller's Select + X.
|
||||
private func statsHoldElapsed() {
|
||||
playPauseTimer = nil
|
||||
statsHoldFired = true
|
||||
StatsVerbosity.cycle()
|
||||
}
|
||||
|
||||
/// A Play/Pause tap, delivered now that it resolved as one: the right button down, its
|
||||
/// release `tapPress` behind so the pair can't collapse into nothing downstream.
|
||||
private func deliverRightClick() {
|
||||
// A previous tap's owed release goes out FIRST — two taps inside `tapPress` would
|
||||
// otherwise send the host two downs in a row (the rule GamepadCapture's held-back Select
|
||||
// tap follows for the same reason).
|
||||
finishRightClick()
|
||||
setButton(3, down: true)
|
||||
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in self?.finishRightClick() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
rightReleaseTimer = timer
|
||||
}
|
||||
|
||||
/// Release a tap's right button if one is still owed; nothing otherwise.
|
||||
private func finishRightClick() {
|
||||
guard rightReleaseTimer != nil else { return }
|
||||
rightReleaseTimer?.invalidate()
|
||||
rightReleaseTimer = nil
|
||||
setButton(3, down: false)
|
||||
}
|
||||
|
||||
/// Drop any in-flight Play/Pause state (unbind / stop). Timers only — a right button already
|
||||
/// sent down is held state, and `releaseHeld` is what lifts it.
|
||||
private func cancelPlayPause() {
|
||||
playPauseTimer?.invalidate()
|
||||
playPauseTimer = nil
|
||||
rightReleaseTimer?.invalidate()
|
||||
rightReleaseTimer = nil
|
||||
statsHoldFired = false
|
||||
}
|
||||
|
||||
private func menuChanged(pressed: Bool) {
|
||||
if pressed {
|
||||
menuDownAt = Date()
|
||||
|
||||
@@ -5,8 +5,9 @@ import XCTest
|
||||
|
||||
/// The escape chord's mask and its GameController alias list have to describe the same four
|
||||
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
|
||||
/// is on, but only of `escapeChordElements` while it is off — so if the alias list ever stops
|
||||
/// covering the mask, the missing button's press stays the system's and the chord never completes.
|
||||
/// is on, but only of `chordElements` — `escapeChordElements` plus the stats chord's — while it is
|
||||
/// off, so if this alias list ever stops covering the mask, the missing button's press stays the
|
||||
/// system's and the chord never completes. (`GamepadStatsChordTests` pins the claim list itself.)
|
||||
///
|
||||
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
|
||||
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import GameController
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// The stats chord (Select + X) has the same drift hazard as the escape chord it sits beside: its
|
||||
/// mask and its GameController alias list must describe the same buttons, and every element some
|
||||
/// chord reads has to appear in the list a NON-forwarding slot claims — otherwise that button's
|
||||
/// press stays the system's and the chord silently never completes.
|
||||
///
|
||||
/// It matters most on tvOS, where this is the only way to the statistics overlay at all (no
|
||||
/// keyboard for ⌃⌥⇧S, no touchscreen for the three-finger tap). The failure looks like nothing
|
||||
/// happening, so it is pinned here rather than left to the comments.
|
||||
@MainActor
|
||||
final class GamepadStatsChordTests: XCTestCase {
|
||||
|
||||
/// The intended alias↔bit pairing, spelled out independently of the implementation.
|
||||
private let pairing: [(alias: String, bit: UInt32)] = [
|
||||
(GCInputButtonOptions, GamepadWire.back),
|
||||
(GCInputButtonX, GamepadWire.x),
|
||||
]
|
||||
|
||||
func testChordMaskIsExactlyTheTwoPairedButtons() {
|
||||
XCTAssertEqual(
|
||||
pairing.reduce(UInt32(0)) { $0 | $1.bit },
|
||||
GamepadCapture.statsChord,
|
||||
"the chord mask and the alias pairing describe different buttons")
|
||||
}
|
||||
|
||||
func testAliasListMirrorsTheMask() {
|
||||
XCTAssertEqual(
|
||||
GamepadCapture.statsChordElements.count,
|
||||
GamepadCapture.statsChord.nonzeroBitCount,
|
||||
"alias list and chord mask differ in size")
|
||||
XCTAssertEqual(GamepadCapture.statsChordElements, pairing.map(\.alias))
|
||||
}
|
||||
|
||||
/// The two chords must not be reachable through one another: pressing toward the exit chord
|
||||
/// may not cycle the overlay on the way, and holding the stats chord may not arm a disconnect.
|
||||
/// Select is the one button they share by design — everything else has to be disjoint.
|
||||
func testChordsOverlapOnlyOnSelect() {
|
||||
XCTAssertEqual(
|
||||
GamepadCapture.statsChord & GamepadCapture.escapeChord,
|
||||
GamepadWire.back,
|
||||
"the stats and escape chords share a button other than Select")
|
||||
// Neither is a subset of the other, so completing one can never complete the other.
|
||||
XCTAssertNotEqual(
|
||||
GamepadCapture.statsChord & GamepadCapture.escapeChord, GamepadCapture.statsChord)
|
||||
XCTAssertNotEqual(
|
||||
GamepadCapture.statsChord & GamepadCapture.escapeChord, GamepadCapture.escapeChord)
|
||||
}
|
||||
|
||||
/// `chordElements` is what `openSlot` claims when forwarding is OFF. It must cover BOTH
|
||||
/// chords' aliases and repeat none of them (a duplicate would mean a bit with no element).
|
||||
func testClaimListCoversBothChordsWithoutDuplicates() {
|
||||
let claim = GamepadCapture.chordElements
|
||||
for alias in GamepadCapture.escapeChordElements + GamepadCapture.statsChordElements {
|
||||
XCTAssertTrue(claim.contains(alias), "\(alias) is read by a chord but never claimed")
|
||||
}
|
||||
XCTAssertEqual(Set(claim).count, claim.count, "a repeated alias in the claim list")
|
||||
// Shared Select means the union is one shorter than the two lists laid end to end.
|
||||
XCTAssertEqual(
|
||||
claim.count,
|
||||
GamepadCapture.escapeChordElements.count + GamepadCapture.statsChordElements.count - 1)
|
||||
}
|
||||
|
||||
/// A cycle is a pure rotation through the four tiers — the chord fires `StatsVerbosity.cycle`,
|
||||
/// and a tier that dead-ended would strand a tvOS user with no other way back.
|
||||
func testCycleReachesEveryTierAndReturns() {
|
||||
var tier = StatsVerbosity.off
|
||||
var seen: [StatsVerbosity] = []
|
||||
for _ in 0..<StatsVerbosity.allCases.count {
|
||||
seen.append(tier)
|
||||
tier = tier.next()
|
||||
}
|
||||
XCTAssertEqual(Set(seen).count, StatsVerbosity.allCases.count, "a tier is unreachable")
|
||||
XCTAssertEqual(tier, .off, "the cycle does not return to where it started")
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,9 @@ to one readable line.
|
||||
- **Apple TV** has no keyboard path, and a short press of the Siri Remote's Back button deliberately
|
||||
does nothing — so a controller's B button can't end your session by accident. To leave, **hold
|
||||
Back for about a second and let go**. During a session the remote's touch surface drives the host
|
||||
cursor, a press is a left click, and Play/Pause is a right click.
|
||||
cursor, a press is a left click, and Play/Pause is a right click — **hold Play/Pause** instead and
|
||||
it cycles the [stats overlay](/docs/stats). With a controller in hand, **Select + X** does the
|
||||
same on every Apple client.
|
||||
|
||||
### Leaving with a controller
|
||||
|
||||
@@ -99,6 +101,19 @@ there the client stops opening the controller at all, which is the point of the
|
||||
**Ctrl+Alt+Shift+D** or the client's own UI to leave instead. The Apple and Android apps keep
|
||||
watching for the chord either way.
|
||||
|
||||
### Statistics with a controller
|
||||
|
||||
The **Apple** apps reserve a second chord: **Select + X**, which cycles the
|
||||
[stats overlay](/docs/stats) one level each time you complete it. It is for the moment your hands
|
||||
are on a controller and the usual routes aren't — no keyboard for **⌃⌥⇧S**, no free screen for the
|
||||
three-finger tap — and on **Apple TV** it is the only way there with a pad. X is deliberately none
|
||||
of the four leave-chord buttons, so reaching for one chord never trips the other. Both buttons
|
||||
still reach the game; only the overlay changes locally.
|
||||
|
||||
On the **Siri Remote**, **hold Play/Pause** for about half a second instead. A quick tap of that
|
||||
button is still a right click — the click is simply sent when you let go, so the hold has
|
||||
something to be.
|
||||
|
||||
### The guide button (Xbox / PS / Steam) and Quick Access
|
||||
|
||||
A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is
|
||||
|
||||
@@ -45,6 +45,13 @@ in-stream:
|
||||
| Linux · Windows · Steam Deck | **Ctrl+Alt+Shift+S** |
|
||||
| macOS / iPad (pointer or trackpad) | **⌃⌥⇧S** or a **three-finger tap** |
|
||||
| Android · iPhone | a **three-finger tap** |
|
||||
| Apple TV | **hold Play/Pause** on the Siri Remote |
|
||||
| Any Apple client, controller in hand | **Select + X** |
|
||||
|
||||
**Select + X** is there for the times your hands are on a controller and the other routes aren't:
|
||||
no keyboard for the combo, no free screen for the tap. On an **Apple TV** it is the only one of
|
||||
the two you can reach with a game controller, and holding **Play/Pause** is the equivalent on the
|
||||
Siri Remote — a *tap* on that button still right-clicks, only the hold cycles the overlay.
|
||||
|
||||
**Ctrl+Alt+Shift+S** is one of a small set of shortcuts a stream reserves; the others — release
|
||||
captured input, switch mouse mode, disconnect, mute the microphone — are in
|
||||
|
||||
Reference in New Issue
Block a user