diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index ea3caede..c469c2a8 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 7460ea0d..d51ef7bc 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -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), diff --git a/clients/apple/Sources/PunktfunkKit/Input/SiriRemotePointer.swift b/clients/apple/Sources/PunktfunkKit/Input/SiriRemotePointer.swift index f1799596..c54842fd 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/SiriRemotePointer.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/SiriRemotePointer.swift @@ -34,10 +34,26 @@ public final class SiriRemotePointer { private var heldButtons: Set = [] /// 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() diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift index 0531f7e7..7af92c43 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift @@ -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. diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadStatsChordTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadStatsChordTests.swift new file mode 100644 index 00000000..273a41eb --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadStatsChordTests.swift @@ -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..