diff --git a/AGENTS.md b/AGENTS.md index 90d514c1..f6612b23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,4 +13,3 @@ For every task that changes repository content, complete this checklist before r 4. Do not mark the task complete until both checks pass. If a local executable has the wrong version, use the pinned CI/pre-commit tool environment instead of running the mismatched executable. 5. Report both check results in the final response. - diff --git a/CloudNow/Localization/AppLocalization.swift b/CloudNow/Localization/AppLocalization.swift index a896cfd4..a87645b7 100644 --- a/CloudNow/Localization/AppLocalization.swift +++ b/CloudNow/Localization/AppLocalization.swift @@ -202,6 +202,30 @@ enum L10n { } } + static func controllerSequenceButtonLabel(_ button: ControllerSequenceButton) -> String { + switch button { + case .dpadUp: text("d_pad_up") + case .dpadDown: text("d_pad_down") + case .dpadLeft: text("d_pad_left") + case .dpadRight: text("d_pad_right") + case .buttonA: text("a") + case .buttonB: text("b") + case .buttonX: text("x") + case .buttonY: text("y") + case .menu: text("menu_(≡)") + case .options: text("options/view_(⊟)") + case .leftShoulder: text("l1") + case .rightShoulder: text("r1") + case .leftThumbstick: text("l3") + case .rightThumbstick: text("r3") + } + } + + static func controllerButtonSequenceLabel(_ sequence: ControllerButtonSequence) -> String { + let labels = sequence.buttons.map { controllerSequenceButtonLabel($0) } + return labels.isEmpty ? text("not_set") : labels.joined(separator: " + ") + } + static func remoteInputModeLabel(_ mode: RemoteInputMode) -> String { switch mode { case .gamepad: text("remote_gamepad") diff --git a/CloudNow/Localization/L10nEN.swift b/CloudNow/Localization/L10nEN.swift index 454030b1..38b3e8ab 100644 --- a/CloudNow/Localization/L10nEN.swift +++ b/CloudNow/Localization/L10nEN.swift @@ -142,6 +142,10 @@ enum L10nEN { "controller_rumble_intensity_description": "Boosts vibration strength. Don't set it too high — very strong sustained rumble can wear out or damage a controller's motors. Applies to the next session.", "overlay_button": "Overlay Button", "overlay_button_description": "Long-press this button during play to open the app overlay. Switch if it conflicts with an in-game action.", + "text_input_buttons": "Text Input Buttons", + "text_input_buttons_description": "Choose the controller button chord that opens the Apple TV keyboard during streaming.", + "text_input_hold_delay": "Text Input Hold Delay", + "text_input_hold_delay_description": "How long the text input buttons must be held before the Apple TV keyboard opens. Default: 150 ms.", "steam_overlay_gesture": "Steam Overlay Gesture", "steam_overlay_gesture_description": "Long-press the other button (the one not set as Overlay Button) to send Shift+Tab and open the Steam overlay. For example, with Overlay on Start, long-press View/Back.", "default_input_mode": "Default Input Mode", @@ -196,6 +200,42 @@ enum L10nEN { "end_session_message": "This will end your GeForce NOW session. To return later, use Leave Game instead.", "resume": "Resume", "leave_game": "Leave Game", + "controller_text_entry_title": "Type with Apple TV Keyboard", + "controller_text_entry_instructions": "Enter text locally, then send it to GeForce NOW when you're done.", + "controller_text_entry_placeholder": "Enter text", + "controller_text_entry_done_hint": "Press Done to review your text, then choose Send when you're ready.", + "controller_text_entry_send": "Send", + "controller_text_entry_unsupported_characters": "This text includes characters CloudNow can't send yet. Edit the text and try again.", + "capture_text_input_buttons": "Set Text Input Buttons", + "capture_text_input_buttons_idle_instructions": "Select Start Listening when you're ready to set a new controller button chord.", + "capture_text_input_buttons_instructions": "Press the controller buttons you want to use together. The sequence is saved after you release them all.", + "start_listening": "Start Listening", + "back_to_settings": "Back to Settings", + "current_sequence": "Current Sequence", + "release_buttons_to_begin": "Release all controller buttons to begin", + "press_buttons_now": "Press buttons now", + "release_buttons_to_save": "Release buttons to save", + "detected_sequence": "Detected Sequence", + "button_sequence_empty": "No supported buttons were detected.", + "button_sequence_too_long": "Use up to four buttons in a sequence.", + "button_sequence_conflicts_overlay": "That sequence uses the current overlay button by itself. Choose a different button or add another button.", + "button_sequence_conflicts_steam": "That sequence matches the current Steam overlay button by itself. Choose a different button or add another button.", + "not_set": "Not Set", + "d_pad_up": "D-Pad Up", + "d_pad_down": "D-Pad Down", + "d_pad_left": "D-Pad Left", + "d_pad_right": "D-Pad Right", + "a": "A", + "b": "B", + "x": "X", + "y": "Y", + "menu_(≡)": "Menu (≡)", + "options/view_(⊟)": "Options/View (⊟)", + "l1": "L1", + "r1": "R1", + "l3": "L3", + "r3": "R3", + "statistics_disabled": "Statistics disabled", "rtt": "RTT", "fps": "FPS", "bitrate": "Bitrate", diff --git a/CloudNow/Session/SessionState.swift b/CloudNow/Session/SessionState.swift index 057809c6..86c3cf6b 100644 --- a/CloudNow/Session/SessionState.swift +++ b/CloudNow/Session/SessionState.swift @@ -7,11 +7,16 @@ nonisolated struct StreamSettings: Codable, Equatable { static let maxSelectableBitrateKbps = 100_000 static let minControllerDeadzone = 0.0 static let maxControllerDeadzone = 0.30 + static let minTextInputTriggerDelayMs = 50 + static let maxTextInputTriggerDelayMs = 1500 + static let textInputTriggerDelayStepMs = 50 + static let defaultTextInputTriggerDelayMs = 150 static let minRumbleIntensity = 0.0 static let maxRumbleIntensity = 2.0 static let defaultKeyboardLayout = L10n.keyboardLayoutCode() static let automaticGameLanguage = "automatic" static let defaultGameLanguage = automaticGameLanguage + static let defaultTextInputTriggerSequence = ControllerButtonSequence(buttons: [.options, .buttonY]) var resolution: String = "1920x1080" var fps: Int = 60 @@ -38,6 +43,18 @@ nonisolated struct StreamSettings: Codable, Equatable { /// Which controller button triggers the GFN overlay on long-press. Default: Start (≡). var overlayTriggerButton: OverlayTriggerButton = .start + /// Button chord that opens the local text-input overlay during streaming. + var textInputTriggerSequence: ControllerButtonSequence = Self.defaultTextInputTriggerSequence + /// How long the text-input trigger chord must be held before opening the local keyboard. + var textInputTriggerDelayMs: Int = Self.defaultTextInputTriggerDelayMs { + didSet { + textInputTriggerDelayMs = min( + max(textInputTriggerDelayMs, Self.minTextInputTriggerDelayMs), + Self.maxTextInputTriggerDelayMs + ) + } + } + /// Default remote/controller input mode when a stream session starts. var defaultRemoteInputMode: RemoteInputMode = .gamepad /// How the streaming server is chosen. Server automatic delegates routing to @@ -104,7 +121,9 @@ nonisolated struct StreamSettings: Codable, Equatable { extension StreamSettings { enum CodingKeys: String, CodingKey { case resolution, fps, maxBitrateKbps, codec, colorPreference, keyboardLayout - case gameLanguage, enableL4S, micEnabled, rumbleEnabled, rumbleIntensity, controllerDeadzone, overlayTriggerButton + case gameLanguage, enableL4S, micEnabled, controllerDeadzone, overlayTriggerButton + case textInputTriggerSequence, textInputTriggerDelayMs + case rumbleEnabled, rumbleIntensity case defaultRemoteInputMode, preferredZoneUrl case serverRoutingMode, preferredRegionName, preferredRegionAddress case enableSteamOverlayGesture @@ -134,6 +153,8 @@ extension StreamSettings { rumbleIntensity = try c.decodeIfPresent(Double.self, forKey: .rumbleIntensity) ?? d.rumbleIntensity controllerDeadzone = try c.decodeIfPresent(Double.self, forKey: .controllerDeadzone) ?? d.controllerDeadzone overlayTriggerButton = try c.decodeIfPresent(OverlayTriggerButton.self, forKey: .overlayTriggerButton) ?? d.overlayTriggerButton + textInputTriggerSequence = try c.decodeIfPresent(ControllerButtonSequence.self, forKey: .textInputTriggerSequence) ?? d.textInputTriggerSequence + textInputTriggerDelayMs = try c.decodeIfPresent(Int.self, forKey: .textInputTriggerDelayMs) ?? d.textInputTriggerDelayMs defaultRemoteInputMode = try c.decodeIfPresent(RemoteInputMode.self, forKey: .defaultRemoteInputMode) ?? d.defaultRemoteInputMode preferredZoneUrl = try c.decodeIfPresent(String.self, forKey: .preferredZoneUrl) serverRoutingMode = try c.decodeIfPresent(ServerRoutingMode.self, forKey: .serverRoutingMode) @@ -180,6 +201,8 @@ extension StreamSettings { try c.encode(rumbleIntensity, forKey: .rumbleIntensity) try c.encode(controllerDeadzone, forKey: .controllerDeadzone) try c.encode(overlayTriggerButton, forKey: .overlayTriggerButton) + try c.encode(textInputTriggerSequence, forKey: .textInputTriggerSequence) + try c.encode(textInputTriggerDelayMs, forKey: .textInputTriggerDelayMs) try c.encode(defaultRemoteInputMode, forKey: .defaultRemoteInputMode) try c.encodeIfPresent(preferredZoneUrl, forKey: .preferredZoneUrl) try c.encode(serverRoutingMode, forKey: .serverRoutingMode) @@ -252,6 +275,76 @@ nonisolated enum OverlayTriggerButton: String, Codable, CaseIterable { } } +nonisolated enum ControllerSequenceButton: String, Codable, CaseIterable, Hashable { + case dpadUp + case dpadDown + case dpadLeft + case dpadRight + case buttonA + case buttonB + case buttonX + case buttonY + case menu + case options + case leftShoulder + case rightShoulder + case leftThumbstick + case rightThumbstick + + fileprivate var sortOrder: Int { + switch self { + case .dpadUp: 0 + case .dpadDown: 1 + case .dpadLeft: 2 + case .dpadRight: 3 + case .buttonA: 4 + case .buttonB: 5 + case .buttonX: 6 + case .buttonY: 7 + case .menu: 8 + case .options: 9 + case .leftShoulder: 10 + case .rightShoulder: 11 + case .leftThumbstick: 12 + case .rightThumbstick: 13 + } + } + + @MainActor var label: String { + L10n.controllerSequenceButtonLabel(self) + } +} + +nonisolated struct ControllerButtonSequence: Codable, Equatable { + static let maxButtons = 4 + + var buttons: [ControllerSequenceButton] + + init(buttons: [ControllerSequenceButton]) { + self.buttons = Array(Set(buttons)).sorted { $0.sortOrder < $1.sortOrder } + } + + var isEmpty: Bool { + buttons.isEmpty + } + + var count: Int { + buttons.count + } + + func contains(_ button: ControllerSequenceButton) -> Bool { + buttons.contains(button) + } + + func asSet() -> Set { + Set(buttons) + } + + @MainActor var label: String { + L10n.controllerButtonSequenceLabel(self) + } +} + nonisolated enum AppLaunchMode: String, Codable, CaseIterable { case `default` case bigPicture diff --git a/CloudNow/Streaming/GFNStreamController.swift b/CloudNow/Streaming/GFNStreamController.swift index 0326b768..5c8b579c 100644 --- a/CloudNow/Streaming/GFNStreamController.swift +++ b/CloudNow/Streaming/GFNStreamController.swift @@ -35,6 +35,17 @@ enum StreamState: Equatable { case sessionEnded } +enum StreamOverlayState: Equatable { + case none + case pauseMenu + case textEntry +} + +enum ControllerTextEntrySubmissionResult { + case accepted + case unsupportedCharacters +} + // MARK: - Stream Statistics nonisolated struct StreamStats: Equatable { @@ -205,6 +216,8 @@ final class GFNStreamController: NSObject { /// Incremented each time the user presses Menu while VideoSurfaceView is first responder. /// SwiftUI observes this via .onChange to toggle the HUD overlay. private(set) var menuPressCount: Int = 0 + /// Incremented when the controller keyboard shortcut requests local text entry. + private(set) var textEntryRequestCount: Int = 0 private var peerConnection: LKRTCPeerConnection? private var inputDataChannel: LKRTCDataChannel? @@ -270,6 +283,9 @@ final class GFNStreamController: NSObject { var onReconnectNeeded: (@MainActor () async -> SessionInfo?)? private var previousSelectedCandidatePairId = "" private var lastZoneRttFeedbackAt: Date? + private var overlayInputPaused = false + private var controllerTextEntryActive = false + private var replayInputPaused = false private static let factory: LKRTCPeerConnectionFactory = { LKRTCInitializeSSL() @@ -472,6 +488,54 @@ final class GFNStreamController: NSObject { inputSender?.setPaused(paused) } + func setOverlayInputPaused(_ paused: Bool) { + overlayInputPaused = paused + syncInputPauseState() + } + + func beginControllerTextEntry() { + guard state == .streaming, !controllerTextEntryActive else { return } + controllerTextEntryActive = true + replayInputPaused = false + syncInputPauseState() + textEntryRequestCount += 1 + } + + func cancelControllerTextEntry() { + controllerTextEntryActive = false + replayInputPaused = false + syncInputPauseState() + } + + func submitControllerTextEntry( + _ text: String, + completion: @escaping (ControllerTextEntrySubmissionResult) -> Void + ) { + guard let inputSender else { + controllerTextEntryActive = false + replayInputPaused = false + syncInputPauseState() + completion(.accepted) + return + } + + let result = inputSender.replaySubmittedText(text) { [weak self] in + Task { @MainActor [weak self] in + self?.replayInputPaused = false + self?.syncInputPauseState() + } + } + guard result == .supported else { + completion(.unsupportedCharacters) + return + } + + controllerTextEntryActive = false + replayInputPaused = true + syncInputPauseState() + completion(.accepted) + } + // MARK: Fail (external error surfacing) func fail(with message: String) { @@ -529,6 +593,7 @@ final class GFNStreamController: NSObject { videoView = nil remoteMode = .gamepad menuPressCount = 0 + textEntryRequestCount = 0 timeWarning = nil videoDiagnostics = VideoPipelineSnapshot() colorState = StreamColorState( @@ -539,6 +604,9 @@ final class GFNStreamController: NSObject { displayHDRSupport: .unknown, fallbackReason: nil ) + overlayInputPaused = false + controllerTextEntryActive = false + replayInputPaused = false state = .idle } @@ -1963,6 +2031,8 @@ extension GFNStreamController: LKRTCDataChannelDelegate { protocolVersion: negotiatedVersion, deadzone: Float(settings.controllerDeadzone), overlayTriggerButton: settings.overlayTriggerButton, + textInputTriggerSequence: settings.textInputTriggerSequence, + textInputTriggerDelayMs: settings.textInputTriggerDelayMs, steamOverlayGestureEnabled: settings.enableSteamOverlayGesture, remoteMode: settings.defaultRemoteInputMode, rumbleEnabled: settings.rumbleEnabled, @@ -1975,6 +2045,9 @@ extension GFNStreamController: LKRTCDataChannelDelegate { self?.remoteMode = mode self?.videoView?.gamepadModeActive = mode != .gamepadMouse } + sender.controllerKeyboardShortcutHandler = { [weak self] in + self?.beginControllerTextEntry() + } sender.start() inputSender = sender inputSendQueue.async { [weak self, sender] in @@ -1982,10 +2055,17 @@ extension GFNStreamController: LKRTCDataChannelDelegate { } // Forward keyboard/mouse events from the video surface to the sender videoView?.inputHandler = sender + syncInputPauseState() } } } +private extension GFNStreamController { + func syncInputPauseState() { + inputSender?.setPaused(overlayInputPaused || controllerTextEntryActive || replayInputPaused) + } +} + // MARK: - DataChannelSender conformance extension GFNStreamController: DataChannelSender { diff --git a/CloudNow/Streaming/InputSender.swift b/CloudNow/Streaming/InputSender.swift index 9a24054f..f1a631d4 100644 --- a/CloudNow/Streaming/InputSender.swift +++ b/CloudNow/Streaming/InputSender.swift @@ -77,7 +77,7 @@ nonisolated enum RemoteInputMode: String, Codable, Equatable { extension RemoteInputMode { /// The Siri Remote drives the mouse pointer alongside the connected controller. - var remoteActsAsMouse: Bool { + nonisolated var remoteActsAsMouse: Bool { self == .gamepadMouse } } @@ -111,6 +111,11 @@ nonisolated enum InputSendDisposition { case superseded } +nonisolated enum SubmittedTextValidationResult { + case supported + case unsupportedCharacters +} + /// Reusable fixed-capacity storage handed exclusively from InputSender to the WebRTC send queue. /// The sender does not mutate a packet again until the completion returns it to the pool. final nonisolated class EncodedInputPacket: @unchecked Sendable { @@ -476,6 +481,19 @@ final nonisolated class InputSender: @unchecked Sendable { var triggered = false } + private struct KeyboardShortcutState { + var buttons: Set + var deadline: UInt64 + var triggered = false + } + + private struct KeyboardShortcutResolution { + let buttons: UInt16 + let suppressesOverlayGestures: Bool + let suppressesSteamGestures: Bool + let replayMask: UInt16? + } + private struct GamepadSnapshot: Equatable { let buttons: UInt16 let leftTrigger: UInt8 @@ -492,12 +510,22 @@ final nonisolated class InputSender: @unchecked Sendable { let button: GCControllerButtonInput } + private struct KeyboardReplayEvent { + let down: Bool + let vk: UInt16 + let scancode: UInt16 + let modifiers: UInt16 + } + /// Called when the user long-presses the overlay trigger button to toggle the GFN overlay. var menuToggleHandler: (@MainActor @Sendable () -> Void)? /// Called when remoteMode changes due to controller connect/disconnect auto-switching. var onRemoteModeChanged: (@MainActor @Sendable (RemoteInputMode) -> Void)? + /// Called when the user presses the controller keyboard shortcut. + var controllerKeyboardShortcutHandler: (() -> Void)? + private weak var channel: DataChannelSender? private let encoder = InputEncoder() private let inputQueue = DispatchQueue(label: "com.cloudnow.input", qos: .userInteractive) @@ -507,6 +535,8 @@ final nonisolated class InputSender: @unchecked Sendable { private var remoteMode: RemoteInputMode = .gamepad private var deadzone: Float = 0.15 private var overlayTriggerButton: OverlayTriggerButton = .start + private var textInputTriggerSequence = StreamSettings.defaultTextInputTriggerSequence + private var textInputTriggerDelayMs = StreamSettings.defaultTextInputTriggerDelayMs private var steamOverlayGestureEnabled = true private var isPaused = false @@ -536,6 +566,8 @@ final nonisolated class InputSender: @unchecked Sendable { private var overlayPresses: [Int: OverlayPressState] = [:] private var overlayReplaySlots = Set() + private var keyboardShortcutStates: [Int: KeyboardShortcutState] = [:] + private var keyboardShortcutReplayMasks: [Int: UInt16] = [:] private var steamHoldTicks: [Int: Int] = [:] private var steamTriggeredSlots = Set() private static let sampleInterval = 8_333_333 @@ -544,6 +576,7 @@ final nonisolated class InputSender: @unchecked Sendable { private static let overlayLongPressThreshold = 216 private static let steamLongPressThreshold = 120 private static let microGamepadSuppressionWindow = UInt64(100_000_000) + private static let replayEventIntervalMs: Int = 18 init(channel: DataChannelSender) { self.channel = channel @@ -598,6 +631,8 @@ final nonisolated class InputSender: @unchecked Sendable { protocolVersion: Int, deadzone: Float, overlayTriggerButton: OverlayTriggerButton, + textInputTriggerSequence: ControllerButtonSequence, + textInputTriggerDelayMs: Int, steamOverlayGestureEnabled: Bool, remoteMode: RemoteInputMode, rumbleEnabled: Bool = true, @@ -607,6 +642,8 @@ final nonisolated class InputSender: @unchecked Sendable { encoder.setProtocolVersion(protocolVersion) self.deadzone = deadzone self.overlayTriggerButton = overlayTriggerButton + self.textInputTriggerSequence = textInputTriggerSequence + self.textInputTriggerDelayMs = textInputTriggerDelayMs self.steamOverlayGestureEnabled = steamOverlayGestureEnabled self.remoteMode = remoteMode self.rumbleEnabled = rumbleEnabled @@ -630,6 +667,8 @@ final nonisolated class InputSender: @unchecked Sendable { if paused { overlayPresses.removeAll() overlayReplaySlots.removeAll() + keyboardShortcutStates.removeAll() + keyboardShortcutReplayMasks.removeAll() steamHoldTicks.removeAll() steamTriggeredSlots.removeAll() releaseHeldDiscreteInputs() @@ -675,6 +714,8 @@ final nonisolated class InputSender: @unchecked Sendable { releaseHeldMouseButtons() overlayPresses.removeAll() overlayReplaySlots.removeAll() + keyboardShortcutStates.removeAll() + keyboardShortcutReplayMasks.removeAll() steamHoldTicks.removeAll() steamTriggeredSlots.removeAll() lastSnapshots.removeAll() @@ -844,6 +885,10 @@ final nonisolated class InputSender: @unchecked Sendable { if changed & overlayButtonMask != 0 { if buttons & overlayButtonMask != 0 { overlayPresses[slot] = OverlayPressState() + } else if keyboardShortcutStates[slot] != nil { + // The keyboard shortcut owns this gesture and will replay the complete chord + // if it was released before its hold delay. + overlayPresses[slot] = nil } else { finishOverlayPress(for: controller, slot: slot) } @@ -862,8 +907,20 @@ final nonisolated class InputSender: @unchecked Sendable { var state = mapGCControllerToXInput(controller, deadzone: deadzone) lastButtons[slot] = state.buttons + let shortcutResolution = applyKeyboardShortcutState( + buttons: state.buttons, + slot: slot, + now: now + ) + state.buttons = shortcutResolution.buttons + if let replayMask = shortcutResolution.replayMask { + sendKeyboardShortcutTap(for: controller, slot: slot, buttonMask: replayMask) + } + if sampleOverlay { - if isOverlayButtonHeld(on: controller) { + if shortcutResolution.suppressesOverlayGestures { + overlayPresses[slot] = nil + } else if isOverlayButtonHeld(on: controller) { var press = overlayPresses[slot] ?? OverlayPressState() press.ticks += 1 if !press.triggered, press.ticks >= Self.overlayLongPressThreshold { @@ -875,7 +932,10 @@ final nonisolated class InputSender: @unchecked Sendable { finishOverlayPress(for: controller, slot: slot) } - if steamOverlayGestureEnabled, isSteamButtonHeld(on: controller) { + if shortcutResolution.suppressesSteamGestures { + steamHoldTicks[slot] = nil + steamTriggeredSlots.remove(slot) + } else if steamOverlayGestureEnabled, isSteamButtonHeld(on: controller) { let ticks = (steamHoldTicks[slot] ?? 0) + 1 steamHoldTicks[slot] = ticks if ticks >= Self.steamLongPressThreshold, @@ -899,6 +959,9 @@ final nonisolated class InputSender: @unchecked Sendable { if steamTriggeredSlots.contains(slot) { state.buttons &= ~steamButtonMask } + if let replayMask = keyboardShortcutReplayMasks[slot] { + state.buttons |= replayMask + } sendGamepadSnapshot( GamepadSnapshot( @@ -1020,6 +1083,140 @@ final nonisolated class InputSender: @unchecked Sendable { } } + private func applyKeyboardShortcutState( + buttons: UInt16, + slot: Int, + now: UInt64 + ) -> KeyboardShortcutResolution { + let shortcutGraceWindow = UInt64(textInputTriggerDelayMs) * 1_000_000 + let shortcutButtons = physicalKeyboardShortcutButtons(from: buttons) + let targetButtons = textInputTriggerSequence.asSet() + if var state = keyboardShortcutStates[slot] { + if state.triggered { + if shortcutButtons.isEmpty { + keyboardShortcutStates[slot] = nil + return KeyboardShortcutResolution( + buttons: buttons, + suppressesOverlayGestures: false, + suppressesSteamGestures: false, + replayMask: nil + ) + } + keyboardShortcutStates[slot] = state + return KeyboardShortcutResolution( + buttons: buttons & ~keyboardShortcutMask(for: shortcutButtons), + suppressesOverlayGestures: true, + suppressesSteamGestures: true, + replayMask: nil + ) + } + + guard shortcutButtons == state.buttons else { + keyboardShortcutStates[slot] = nil + return KeyboardShortcutResolution( + buttons: buttons & ~keyboardShortcutMask(for: shortcutButtons), + suppressesOverlayGestures: false, + suppressesSteamGestures: false, + replayMask: keyboardShortcutMask(for: state.buttons) + ) + } + + if now >= state.deadline { + state.triggered = true + keyboardShortcutStates[slot] = state + notifyControllerKeyboardShortcut() + return KeyboardShortcutResolution( + buttons: buttons & ~keyboardShortcutMask(for: shortcutButtons), + suppressesOverlayGestures: true, + suppressesSteamGestures: true, + replayMask: nil + ) + } + + keyboardShortcutStates[slot] = state + return KeyboardShortcutResolution( + buttons: buttons & ~keyboardShortcutMask(for: shortcutButtons), + suppressesOverlayGestures: true, + suppressesSteamGestures: true, + replayMask: nil + ) + } + + guard !targetButtons.isEmpty, shortcutButtons == targetButtons else { + return KeyboardShortcutResolution( + buttons: buttons, + suppressesOverlayGestures: false, + suppressesSteamGestures: false, + replayMask: nil + ) + } + + // Withhold the full chord immediately. If released before the hold delay, replay it as + // a short gamepad tap; otherwise consume it for local text entry. + keyboardShortcutStates[slot] = KeyboardShortcutState( + buttons: targetButtons, + deadline: now &+ shortcutGraceWindow + ) + return KeyboardShortcutResolution( + buttons: buttons & ~keyboardShortcutMask(for: targetButtons), + suppressesOverlayGestures: true, + suppressesSteamGestures: true, + replayMask: nil + ) + } + + private func sendKeyboardShortcutTap( + for controller: GCController, + slot: Int, + buttonMask: UInt16 + ) { + let state = mapGCControllerToXInput(controller, deadzone: deadzone) + let base = GamepadSnapshot( + buttons: state.buttons & ~buttonMask, + leftTrigger: state.leftTrigger, + rightTrigger: state.rightTrigger, + leftStickX: state.lx, + leftStickY: state.ly, + rightStickX: state.rx, + rightStickY: state.ry, + bitmap: gamepadBitmap + ) + let down = GamepadSnapshot( + buttons: base.buttons | buttonMask, + leftTrigger: base.leftTrigger, + rightTrigger: base.rightTrigger, + leftStickX: base.leftStickX, + leftStickY: base.leftStickY, + rightStickX: base.rightStickX, + rightStickY: base.rightStickY, + bitmap: base.bitmap + ) + keyboardShortcutReplayMasks[slot] = buttonMask + sendGamepadSnapshot(down, slot: slot, force: true) + inputQueue.asyncAfter(deadline: .now() + .milliseconds(17)) { [weak self, weak controller] in + guard let self, + let controller, + let replayMask = keyboardShortcutReplayMasks.removeValue(forKey: slot), + controllerSlots[ObjectIdentifier(controller)] == slot + else { return } + let current = mapGCControllerToXInput(controller, deadzone: deadzone) + sendGamepadSnapshot( + GamepadSnapshot( + buttons: current.buttons & ~replayMask, + leftTrigger: current.leftTrigger, + rightTrigger: current.rightTrigger, + leftStickX: current.lx, + leftStickY: current.ly, + rightStickX: current.rx, + rightStickY: current.ry, + bitmap: gamepadBitmap + ), + slot: slot, + force: true + ) + } + } + private func sendNeutralGamepads() { if extendedControllers.isEmpty, remoteMode == .gamepad { sendGamepadSnapshot(neutralSnapshot(bitmap: gamepadBitmap | Self.connectedGamepadBit(for: 0)), slot: 0, force: true) @@ -1052,6 +1249,97 @@ final nonisolated class InputSender: @unchecked Sendable { overlayTriggerButton == .start ? GFNInput.back : GFNInput.start } + private func physicalKeyboardShortcutButtons(from buttons: UInt16) -> Set { + let pressedButtons = pressedSequenceButtons(from: buttons) + return pressedButtons.intersection(textInputTriggerSequence.asSet()) + } + + private func keyboardShortcutMask(for buttons: Set) -> UInt16 { + buttons.reduce(into: UInt16(0)) { mask, button in + mask |= xinputMask(for: button) + } + } + + private func pressedSequenceButtons(from buttons: UInt16) -> Set { + var pressedButtons = Set() + if buttons & GFNInput.dpadUp != 0 { + pressedButtons.insert(.dpadUp) + } + if buttons & GFNInput.dpadDown != 0 { + pressedButtons.insert(.dpadDown) + } + if buttons & GFNInput.dpadLeft != 0 { + pressedButtons.insert(.dpadLeft) + } + if buttons & GFNInput.dpadRight != 0 { + pressedButtons.insert(.dpadRight) + } + if buttons & GFNInput.buttonA != 0 { + pressedButtons.insert(.buttonA) + } + if buttons & GFNInput.buttonB != 0 { + pressedButtons.insert(.buttonB) + } + if buttons & GFNInput.buttonX != 0 { + pressedButtons.insert(.buttonX) + } + if buttons & GFNInput.buttonY != 0 { + pressedButtons.insert(.buttonY) + } + if buttons & GFNInput.start != 0 { + pressedButtons.insert(.menu) + } + if buttons & GFNInput.back != 0 { + pressedButtons.insert(.options) + } + if buttons & GFNInput.lb != 0 { + pressedButtons.insert(.leftShoulder) + } + if buttons & GFNInput.rb != 0 { + pressedButtons.insert(.rightShoulder) + } + if buttons & GFNInput.ls != 0 { + pressedButtons.insert(.leftThumbstick) + } + if buttons & GFNInput.rs != 0 { + pressedButtons.insert(.rightThumbstick) + } + return pressedButtons + } + + private func xinputMask(for button: ControllerSequenceButton) -> UInt16 { + switch button { + case .dpadUp: + GFNInput.dpadUp + case .dpadDown: + GFNInput.dpadDown + case .dpadLeft: + GFNInput.dpadLeft + case .dpadRight: + GFNInput.dpadRight + case .buttonA: + GFNInput.buttonA + case .buttonB: + GFNInput.buttonB + case .buttonX: + GFNInput.buttonX + case .buttonY: + GFNInput.buttonY + case .menu: + GFNInput.start + case .options: + GFNInput.back + case .leftShoulder: + GFNInput.lb + case .rightShoulder: + GFNInput.rb + case .leftThumbstick: + GFNInput.ls + case .rightThumbstick: + GFNInput.rs + } + } + private func isOverlayButtonHeld(on controller: GCController) -> Bool { guard let pad = controller.extendedGamepad else { return false } switch overlayTriggerButton { @@ -1102,6 +1390,11 @@ final nonisolated class InputSender: @unchecked Sendable { DispatchQueue.main.async { handler?(mode) } } + private func notifyControllerKeyboardShortcut() { + let handler = controllerKeyboardShortcutHandler + DispatchQueue.main.async { handler?() } + } + private func sendMouseButtonNow(down: Bool, button: UInt8) { guard !isPaused else { return } if down { @@ -1176,6 +1469,107 @@ final nonisolated class InputSender: @unchecked Sendable { } } + @discardableResult + func replaySubmittedText( + _ text: String, + appendEnter: Bool = true, + completion: @escaping @Sendable () -> Void + ) -> SubmittedTextValidationResult { + // Reject unsupported text before altering any active remote input state. + guard let events = keyboardReplayPlan(for: text, appendEnter: appendEnter) else { + return .unsupportedCharacters + } + + inputQueue.async { [weak self, events] in + guard let self else { + DispatchQueue.main.async(execute: completion) + return + } + + releaseHeldDiscreteInputs() + sendNeutralGamepads() + replayKeyboardEvents(events, index: 0, completion: completion) + } + + return .supported + } + + private func replayKeyboardEvents( + _ events: [KeyboardReplayEvent], + index: Int, + completion: @escaping @Sendable () -> Void + ) { + guard index < events.count else { + DispatchQueue.main.async(execute: completion) + return + } + + let event = events[index] + emitKeyboard( + down: event.down, + vk: event.vk, + scancode: event.scancode, + modifiers: event.modifiers + ) + + inputQueue.asyncAfter(deadline: .now() + .milliseconds(Self.replayEventIntervalMs)) { [weak self] in + guard let self else { + DispatchQueue.main.async(execute: completion) + return + } + replayKeyboardEvents(events, index: index + 1, completion: completion) + } + } + + private func keyboardReplayPlan( + for text: String, + appendEnter: Bool + ) -> [KeyboardReplayEvent]? { + var events: [KeyboardReplayEvent] = [] + for character in text { + guard let (usage, requiresShift) = Self.usageForCharacter(character), + let keyEvents = keyboardEvents(for: usage, requiresShift: requiresShift) + else { + return nil + } + events.append(contentsOf: keyEvents) + } + + if appendEnter { + guard let enter = keyboardEvents( + for: .keyboardReturnOrEnter, + requiresShift: false + ) else { + return nil + } + events.append(contentsOf: enter) + } + + return events + } + + private func keyboardEvents( + for usage: UIKeyboardHIDUsage, + requiresShift: Bool + ) -> [KeyboardReplayEvent]? { + guard let mapping = Self.hidToKeyMapping[usage] else { return nil } + let shiftModifiers: UInt16 = requiresShift ? 0x0001 : 0 + var events: [KeyboardReplayEvent] = [] + + if requiresShift, let shift = Self.hidToKeyMapping[.keyboardLeftShift] { + events.append(KeyboardReplayEvent(down: true, vk: shift.vk, scancode: shift.scancode, modifiers: shiftModifiers)) + } + + events.append(KeyboardReplayEvent(down: true, vk: mapping.vk, scancode: mapping.scancode, modifiers: shiftModifiers)) + events.append(KeyboardReplayEvent(down: false, vk: mapping.vk, scancode: mapping.scancode, modifiers: shiftModifiers)) + + if requiresShift, let shift = Self.hidToKeyMapping[.keyboardLeftShift] { + events.append(KeyboardReplayEvent(down: false, vk: shift.vk, scancode: shift.scancode, modifiers: 0)) + } + + return events + } + private func controllerTouchpad(for controller: GCController) -> ControllerTouchpad? { if let dualSense = controller.extendedGamepad as? GCDualSenseGamepad { return ControllerTouchpad(surface: dualSense.touchpadPrimary, button: dualSense.touchpadButton) @@ -1358,6 +1752,8 @@ final nonisolated class InputSender: @unchecked Sendable { lastSnapshotSend[slot] = nil overlayPresses[slot] = nil overlayReplaySlots.remove(slot) + keyboardShortcutStates[slot] = nil + keyboardShortcutReplayMasks[slot] = nil steamHoldTicks[slot] = nil steamTriggeredSlots.remove(slot) sendGamepadSnapshot(neutralSnapshot(bitmap: gamepadBitmap), slot: slot, force: true) @@ -1485,6 +1881,78 @@ extension InputSender: InputEventHandler { return mods } + private nonisolated static func usageForCharacter(_ character: Character) -> (UIKeyboardHIDUsage, Bool)? { + if character == " " { + return (.keyboardSpacebar, false) + } + + if character == "\n" || character == "\r" { + return (.keyboardReturnOrEnter, false) + } + + let string = String(character) + if let scalar = string.unicodeScalars.first, string.unicodeScalars.count == 1 { + switch scalar.value { + case 97 ... 122: + let raw = UIKeyboardHIDUsage.keyboardA.rawValue + Int(scalar.value - 97) + if let usage = UIKeyboardHIDUsage(rawValue: raw) { + return (usage, false) + } + case 65 ... 90: + let raw = UIKeyboardHIDUsage.keyboardA.rawValue + Int(scalar.value - 65) + if let usage = UIKeyboardHIDUsage(rawValue: raw) { + return (usage, true) + } + case 49 ... 57: + let raw = UIKeyboardHIDUsage.keyboard1.rawValue + Int(scalar.value - 49) + if let usage = UIKeyboardHIDUsage(rawValue: raw) { + return (usage, false) + } + case 48: + return (.keyboard0, false) + default: + break + } + } + + return shiftedCharacterMappings[character] + } + + private nonisolated static let shiftedCharacterMappings: [Character: (UIKeyboardHIDUsage, Bool)] = [ + "-": (.keyboardHyphen, false), + "_": (.keyboardHyphen, true), + "=": (.keyboardEqualSign, false), + "+": (.keyboardEqualSign, true), + "[": (.keyboardOpenBracket, false), + "{": (.keyboardOpenBracket, true), + "]": (.keyboardCloseBracket, false), + "}": (.keyboardCloseBracket, true), + "\\": (.keyboardBackslash, false), + "|": (.keyboardBackslash, true), + ";": (.keyboardSemicolon, false), + ":": (.keyboardSemicolon, true), + "'": (.keyboardQuote, false), + "\"": (.keyboardQuote, true), + "`": (.keyboardGraveAccentAndTilde, false), + "~": (.keyboardGraveAccentAndTilde, true), + ",": (.keyboardComma, false), + "<": (.keyboardComma, true), + ".": (.keyboardPeriod, false), + ">": (.keyboardPeriod, true), + "/": (.keyboardSlash, false), + "?": (.keyboardSlash, true), + "!": (.keyboard1, true), + "@": (.keyboard2, true), + "#": (.keyboard3, true), + "$": (.keyboard4, true), + "%": (.keyboard5, true), + "^": (.keyboard6, true), + "&": (.keyboard7, true), + "*": (.keyboard8, true), + "(": (.keyboard9, true), + ")": (.keyboard0, true), + ] + private nonisolated static let hidToKeyMapping: [UIKeyboardHIDUsage: (vk: UInt16, scancode: UInt16)] = [ .keyboardA: (0x41, 0x1E), .keyboardB: (0x42, 0x30), .keyboardC: (0x43, 0x2E), .keyboardD: (0x44, 0x20), .keyboardE: (0x45, 0x12), .keyboardF: (0x46, 0x21), diff --git a/CloudNow/UI/SettingsView.swift b/CloudNow/UI/SettingsView.swift index 88114488..bdaa2ebe 100644 --- a/CloudNow/UI/SettingsView.swift +++ b/CloudNow/UI/SettingsView.swift @@ -1,3 +1,4 @@ +import GameController import SwiftUI struct SettingsView: View { @@ -300,6 +301,61 @@ struct SettingsView: View { } .padding(.vertical, 8) } + NavigationLink { + TextInputTriggerSequenceCaptureView( + sequence: $vm.streamSettings.textInputTriggerSequence, + overlayTriggerButton: vm.streamSettings.overlayTriggerButton, + steamOverlayGestureEnabled: vm.streamSettings.enableSteamOverlayGesture + ) + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.text("text_input_buttons")) + Text(L10n.text("text_input_buttons_description")) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + Spacer() + Text(vm.streamSettings.textInputTriggerSequence.label) + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + LabeledContent { + HStack(spacing: 16) { + Button { + vm.streamSettings.textInputTriggerDelayMs = max( + StreamSettings.minTextInputTriggerDelayMs, + vm.streamSettings.textInputTriggerDelayMs - StreamSettings.textInputTriggerDelayStepMs + ) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.plain) + Text("\(vm.streamSettings.textInputTriggerDelayMs) ms") + .monospacedDigit() + .frame(minWidth: 92) + .padding(.horizontal, 24) + Button { + vm.streamSettings.textInputTriggerDelayMs = min( + StreamSettings.maxTextInputTriggerDelayMs, + vm.streamSettings.textInputTriggerDelayMs + StreamSettings.textInputTriggerDelayStepMs + ) + } label: { + Image(systemName: "plus.circle") + } + .buttonStyle(.plain) + } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.text("text_input_hold_delay")) + Text(L10n.text("text_input_hold_delay_description")) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } Toggle(isOn: $vm.streamSettings.enableSteamOverlayGesture) { VStack(alignment: .leading, spacing: 4) { Text(L10n.text("steam_overlay_gesture")) @@ -554,6 +610,282 @@ struct SettingsView: View { ResolutionEntry(res: "3840x2160", badge: "4K", symbol: "4k.tv"), ] + private struct TextInputTriggerSequenceCaptureView: View { + @Binding var sequence: ControllerButtonSequence + let overlayTriggerButton: OverlayTriggerButton + let steamOverlayGestureEnabled: Bool + + @Environment(\.dismiss) private var dismiss + + @FocusState private var focusedControl: FocusedControl? + + @State private var capturePhase: CapturePhase = .idle + @State private var liveButtons = Set() + @State private var lastDetectedSequence: ControllerButtonSequence? + @State private var validationMessage: String? + + private enum CapturePhase { + case idle + case arming + case waiting + case collecting + } + + private enum FocusedControl: Hashable { + case startListening + case back + } + + var body: some View { + Form { + Section { + LabeledContent( + L10n.text("current_sequence"), + value: sequence.label + ) + } + + if capturePhase == .idle { + Section { + Text(L10n.text("capture_text_input_buttons_idle_instructions")) + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 8) + + Button { + beginCapture() + } label: { + Label(L10n.text("start_listening"), systemImage: "dot.radiowaves.left.and.right") + } + .buttonStyle(.borderedProminent) + .focused($focusedControl, equals: .startListening) + + Button { + dismiss() + } label: { + Label(L10n.text("back_to_settings"), systemImage: "chevron.backward") + } + .buttonStyle(.bordered) + .focused($focusedControl, equals: .back) + } + .listRowBackground(Color.clear) + } else { + Section { + Text(L10n.text("capture_text_input_buttons_instructions")) + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 8) + + Text(statusText) + .font(.body.weight(.medium)) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 8) + + if let detectedSequence { + LabeledContent( + L10n.text("detected_sequence"), + value: detectedSequence.label + ) + } + + if let validationMessage { + Text(validationMessage) + .font(.body) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 8) + } + } + .listRowBackground(Color.clear) + } + } + .navigationTitle(L10n.text("capture_text_input_buttons")) + .defaultFocus($focusedControl, .startListening) + .onExitCommand { + guard !isCapturing else { return } + dismiss() + } + .task(id: isCapturing) { + guard isCapturing else { return } + await monitorControllerButtons() + } + } + + private var isCapturing: Bool { + capturePhase != .idle + } + + private var statusText: String { + switch capturePhase { + case .idle: + "" + case .arming: + L10n.text("release_buttons_to_begin") + case .waiting: + L10n.text("press_buttons_now") + case .collecting: + L10n.text("release_buttons_to_save") + } + } + + private var detectedSequence: ControllerButtonSequence? { + if !liveButtons.isEmpty { + return ControllerButtonSequence(buttons: Array(liveButtons)) + } + return lastDetectedSequence + } + + private func beginCapture() { + capturePhase = .arming + liveButtons.removeAll() + lastDetectedSequence = nil + validationMessage = nil + } + + @MainActor + private func updateCapture(with pressedButtons: Set) { + switch capturePhase { + case .idle: + return + + case .arming: + guard pressedButtons.isEmpty else { return } + capturePhase = .waiting + + case .waiting: + guard !pressedButtons.isEmpty else { return } + capturePhase = .collecting + validationMessage = nil + liveButtons = pressedButtons + lastDetectedSequence = ControllerButtonSequence(buttons: Array(pressedButtons)) + + case .collecting: + if !pressedButtons.isEmpty { + liveButtons.formUnion(pressedButtons) + lastDetectedSequence = ControllerButtonSequence(buttons: Array(liveButtons)) + return + } + finalizeCapture() + } + } + + @MainActor + private func finalizeCapture() { + let capturedSequence = ControllerButtonSequence(buttons: Array(liveButtons)) + liveButtons.removeAll() + capturePhase = .waiting + + if let validationMessage = validationMessage(for: capturedSequence) { + self.validationMessage = validationMessage + lastDetectedSequence = capturedSequence + return + } + + sequence = capturedSequence + dismiss() + } + + private func validationMessage(for capturedSequence: ControllerButtonSequence) -> String? { + guard !capturedSequence.isEmpty else { + return L10n.text("button_sequence_empty") + } + guard capturedSequence.count <= ControllerButtonSequence.maxButtons else { + return L10n.text("button_sequence_too_long") + } + if capturedSequence.count == 1, capturedSequence.contains(overlayConflictButton) { + return L10n.text("button_sequence_conflicts_overlay") + } + if steamOverlayGestureEnabled, + capturedSequence.count == 1, + capturedSequence.contains(steamConflictButton) + { + return L10n.text("button_sequence_conflicts_steam") + } + return nil + } + + private var overlayConflictButton: ControllerSequenceButton { + switch overlayTriggerButton { + case .start: + .menu + case .options: + .options + } + } + + private var steamConflictButton: ControllerSequenceButton { + switch overlayTriggerButton { + case .start: + .options + case .options: + .menu + } + } + + private func monitorControllerButtons() async { + while !Task.isCancelled { + let pressedButtons = currentPressedButtons() + await MainActor.run { + updateCapture(with: pressedButtons) + } + try? await Task.sleep(for: .milliseconds(16)) + } + } + + private func currentPressedButtons() -> Set { + guard let pad = GCController.controllers().compactMap(\.extendedGamepad).first else { + return [] + } + + var pressedButtons = Set() + if pad.dpad.up.isPressed { + pressedButtons.insert(.dpadUp) + } + if pad.dpad.down.isPressed { + pressedButtons.insert(.dpadDown) + } + if pad.dpad.left.isPressed { + pressedButtons.insert(.dpadLeft) + } + if pad.dpad.right.isPressed { + pressedButtons.insert(.dpadRight) + } + if pad.buttonA.isPressed { + pressedButtons.insert(.buttonA) + } + if pad.buttonB.isPressed { + pressedButtons.insert(.buttonB) + } + if pad.buttonX.isPressed { + pressedButtons.insert(.buttonX) + } + if pad.buttonY.isPressed { + pressedButtons.insert(.buttonY) + } + if pad.buttonMenu.isPressed { + pressedButtons.insert(.menu) + } + if pad.buttonOptions?.isPressed == true { + pressedButtons.insert(.options) + } + if pad.leftShoulder.isPressed { + pressedButtons.insert(.leftShoulder) + } + if pad.rightShoulder.isPressed { + pressedButtons.insert(.rightShoulder) + } + if pad.leftThumbstickButton?.isPressed == true { + pressedButtons.insert(.leftThumbstick) + } + if pad.rightThumbstickButton?.isPressed == true { + pressedButtons.insert(.rightThumbstick) + } + return pressedButtons + } + } + private enum DataDialog: Equatable { case confirmClearCache case confirmResetAllData diff --git a/CloudNow/UI/StreamView.swift b/CloudNow/UI/StreamView.swift index e13ac268..3a3a9b67 100644 --- a/CloudNow/UI/StreamView.swift +++ b/CloudNow/UI/StreamView.swift @@ -12,6 +12,11 @@ private enum LoadingPhase: Equatable { } struct StreamView: View { + private enum TextEntryFocusTarget: Hashable { + case field + case send + } + let game: GameInfo var settings: StreamSettings = .init() var existingSession: ActiveSessionInfo? @@ -25,11 +30,14 @@ struct StreamView: View { @Environment(GamesViewModel.self) var viewModel @Environment(\.colorScheme) private var colorScheme @State private var streamController = GFNStreamController() - @State private var showOverlay = false + @State private var overlayState: StreamOverlayState = .none @State private var showExitConfirmation = false @State private var loadingPhase: LoadingPhase = .finding @State private var createdSession: SessionInfo? @State private var sessionToken: String? + @State private var textEntryText = "" + @State private var textEntryValidationMessage: String? + @FocusState private var textEntryFocus: TextEntryFocusTarget? /// Per-ad state tracking to avoid duplicate reports @State private var adReportedAction: [String: AdAction] = [:] @@ -87,18 +95,21 @@ struct StreamView: View { // active: non-streaming states (loading, error) and while the pause menu holds focus — // there, B/Menu closes the menu just like Resume. .onChange(of: streamController.menuPressCount) { _, _ in - toggleOverlay() + togglePauseMenu() + } + .onChange(of: streamController.textEntryRequestCount) { _, _ in + presentControllerTextEntry() } .onExitCommand { - if showOverlay { - toggleOverlay() + if overlayState != .none { + togglePauseMenu() } else if streamController.state != .streaming { disconnect() } } .onPlayPauseCommand { guard streamController.state == .streaming else { return } - toggleOverlay() + togglePauseMenu() } } @@ -343,22 +354,16 @@ struct StreamView: View { // MARK: Streaming private var streamingView: some View { - VideoSurfaceViewRepresentable(streamController: streamController, showOverlay: showOverlay) + VideoSurfaceViewRepresentable(streamController: streamController, showOverlay: overlayState != .none) .ignoresSafeArea() - // The video is a UIViewControllerRepresentable, which (unlike a plain view) can - // expand to the union of its ZStack siblings' content. A tall Statistics HUD - // (Standard + Diagnostics) grew the layout and zoomed the video via - // resizeAspectFill. An overlay is sized to the video and can never grow it, so - // the HUD/menu/warning live here instead of as ZStack siblings. + // Keep HUDs and menus within the video bounds so they cannot alter its aspect-fill layout. .overlay { ZStack { - if showOverlay { + if overlayState == .pauseMenu { pauseMenu .transition(.move(edge: .leading).combined(with: .opacity)) } - // Stays visible while the pause menu is open (the menu is a left sidebar) - // so cycling the Statistics level takes effect on screen immediately. if streamController.statsMode != .off { StatsHUDView(streamController: streamController) .padding(.top, 60) @@ -367,7 +372,12 @@ struct StreamView: View { .transition(.opacity) } - if let warning = streamController.timeWarning, !showOverlay { + if overlayState == .textEntry { + controllerTextEntryOverlay + .transition(.opacity.combined(with: .scale(scale: 0.96))) + } + + if let warning = streamController.timeWarning, overlayState == .none { timeWarningBanner(warning) .transition(.move(edge: .top).combined(with: .opacity)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -375,12 +385,12 @@ struct StreamView: View { } } .animation(.easeInOut(duration: 0.4), value: streamController.timeWarning) - .animation(.easeInOut(duration: 0.2), value: showOverlay) + .animation(.easeInOut(duration: 0.2), value: overlayState) .animation(.easeInOut(duration: 0.2), value: streamController.statsMode) - .onChange(of: showOverlay) { _, showing in - // Pause game input while overlay is open in gamepad mode so D-pad - // navigates overlay buttons instead of moving the in-game character. - streamController.setInputPaused(showing) + .onChange(of: overlayState) { _, state in + let shouldPause = state != .none + streamController.setOverlayInputPaused(shouldPause) + textEntryFocus = state == .textEntry ? .field : nil } .alert(L10n.text("end_session_title"), isPresented: $showExitConfirmation) { Button(L10n.text("end_session"), role: .destructive) { disconnect() } @@ -397,7 +407,7 @@ struct StreamView: View { private var pauseMenu: some View { VStack(alignment: .leading, spacing: 16) { Button { - toggleOverlay() + togglePauseMenu() } label: { Label(L10n.text("resume"), systemImage: "play.fill") .foregroundStyle(Color.black.opacity(0.84)) @@ -467,6 +477,75 @@ struct StreamView: View { .ignoresSafeArea() } + private var controllerTextEntryOverlay: some View { + VStack(spacing: 24) { + Text(L10n.text("controller_text_entry_title")) + .font(.title.weight(.semibold)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + + Text(L10n.text("controller_text_entry_instructions")) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + VStack(spacing: 12) { + TextField(L10n.text("controller_text_entry_placeholder"), text: $textEntryText) + .multilineTextAlignment(.center) + .focused($textEntryFocus, equals: .field) + .onChange(of: textEntryText) { _, _ in + textEntryValidationMessage = nil + } + .onSubmit { + textEntryFocus = .send + } + + Text(L10n.text("controller_text_entry_done_hint")) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if let textEntryValidationMessage { + Text(textEntryValidationMessage) + .font(.footnote.weight(.medium)) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + + HStack(spacing: 20) { + Button(L10n.text("cancel")) { + cancelControllerTextEntry() + } + .buttonStyle(.bordered) + + Button(L10n.text("controller_text_entry_send")) { + submitControllerTextEntry() + } + .focused($textEntryFocus, equals: .send) + .buttonStyle(.borderedProminent) + .disabled(textEntryText.isEmpty) + } + } + .frame(maxWidth: 680) + .padding(.horizontal, 42) + .padding(.vertical, 36) + .background( + LinearGradient( + colors: [.black.opacity(0.90), Color(red: 0.10, green: 0.12, blue: 0.16).opacity(0.92)], + startPoint: .top, + endPoint: .bottom + ), + in: RoundedRectangle(cornerRadius: 30, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .stroke(.white.opacity(0.18), lineWidth: 1) + } + .shadow(color: .black.opacity(0.4), radius: 32, y: 14) + } + private var pauseMenuBackgroundColor: Color { colorScheme == .dark ? .black.opacity(0.75) : .white.opacity(0.82) } @@ -926,6 +1005,7 @@ struct StreamView: View { /// Leaves the stream locally without stopping the server session. /// GFN keeps the session alive for ~1–2 minutes so it can be resumed from home. private func leave() { + overlayState = .none if let session = createdSession { onLeave?(game, session) } @@ -934,6 +1014,7 @@ struct StreamView: View { } private func disconnect() { + overlayState = .none // Intentional end — clear any pending resumable session viewModel.resumableSession = nil viewModel.clearLastSession() @@ -1028,10 +1109,50 @@ struct StreamView: View { } } - private func toggleOverlay() { - showOverlay.toggle() - // Pause input forwarding while the overlay is visible so swipes don't move - // the game cursor and keyboard shortcuts don't reach the game accidentally. - streamController.setInputPaused(showOverlay) + private func togglePauseMenu() { + if overlayState == .textEntry { + streamController.cancelControllerTextEntry() + textEntryText = "" + textEntryValidationMessage = nil + overlayState = .pauseMenu + return + } + overlayState = overlayState == .pauseMenu ? .none : .pauseMenu + } + + private func presentControllerTextEntry() { + guard streamController.state == .streaming, overlayState == .none else { + // Input is already paused for the accepted shortcut. Do not leave it paused when + // another overlay prevents the text-entry UI from being presented. + streamController.cancelControllerTextEntry() + return + } + textEntryText = "" + textEntryValidationMessage = nil + overlayState = .textEntry + textEntryFocus = .field + } + + private func cancelControllerTextEntry() { + overlayState = .none + textEntryText = "" + textEntryValidationMessage = nil + streamController.cancelControllerTextEntry() + } + + private func submitControllerTextEntry() { + let text = textEntryText + streamController.submitControllerTextEntry(text) { result in + switch result { + case .accepted: + overlayState = .none + textEntryText = "" + textEntryValidationMessage = nil + textEntryFocus = nil + case .unsupportedCharacters: + textEntryValidationMessage = L10n.text("controller_text_entry_unsupported_characters") + textEntryFocus = .field + } + } } }