Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e8b6fd1
Add controller keyboard shortcut plumbing
omgitsalexl Jul 7, 2026
77fb81f
Add in-stream text entry overlay UI
omgitsalexl Jul 7, 2026
1eb7b3d
Add text entry localization strings
omgitsalexl Jul 7, 2026
ab35294
Restore pause overlay alongside text entry
omgitsalexl Jul 8, 2026
b0a655c
Add text input trigger sequence settings model
omgitsalexl Jul 8, 2026
1e83519
Add text input trigger sequence settings UI
omgitsalexl Jul 8, 2026
a97ea6f
Use saved text input trigger sequence at runtime
omgitsalexl Jul 8, 2026
14ca60c
Fix text trigger sequence type mismatches
omgitsalexl Jul 8, 2026
71c938b
Polish text input sequence capture dialog
omgitsalexl Jul 8, 2026
47ba821
Use native settings flow for text input capture
omgitsalexl Jul 8, 2026
0f0b262
Persist text input trigger hold delay
omgitsalexl Jul 8, 2026
ea0e780
Add text input trigger hold delay setting
omgitsalexl Jul 8, 2026
bc97dd3
Use saved hold delay for text input trigger
omgitsalexl Jul 8, 2026
cae6389
Removed unsupported style
omgitsalexl Jul 10, 2026
d1c59d7
Merge main into tvos-controller-keyboard
omgitsalexl Jul 10, 2026
dad7c12
Linted and formatted
omgitsalexl Jul 10, 2026
f1ade86
Fix controller text shortcut gesture handling
omgitsalexl Jul 10, 2026
924ee8e
Validate controller text submission before replay
omgitsalexl Jul 10, 2026
d208f7b
Polish controller text entry overlay
omgitsalexl Jul 10, 2026
37e9489
Harden rejected controller text replay
omgitsalexl Jul 10, 2026
e26db26
Refined chord capture UI/UX
omgitsalexl Jul 15, 2026
c655e2a
Merge origin/main into tvos-controller-keyboard
omgitsalexl Jul 15, 2026
fb69b2b
Implementing `Sendable` removal by @aarikmudgal
omgitsalexl Jul 16, 2026
cc1e7f5
Merge origin/main into tvos-controller-keyboard
omgitsalexl Jul 19, 2026
f69c24e
Fix controller text entry input handling
omgitsalexl Jul 20, 2026
8db6d89
Fix pause menu gamepad input forwarding
omgitsalexl Jul 21, 2026
f70555b
Preserve controller back buttons during capture
omgitsalexl Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

24 changes: 24 additions & 0 deletions CloudNow/Localization/AppLocalization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
40 changes: 40 additions & 0 deletions CloudNow/Localization/L10nEN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
95 changes: 94 additions & 1 deletion CloudNow/Session/SessionState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<ControllerSequenceButton> {
Set(buttons)
}

@MainActor var label: String {
L10n.controllerButtonSequenceLabel(self)
}
}

nonisolated enum AppLaunchMode: String, Codable, CaseIterable {
case `default`
case bigPicture
Expand Down
80 changes: 80 additions & 0 deletions CloudNow/Streaming/GFNStreamController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -529,6 +593,7 @@ final class GFNStreamController: NSObject {
videoView = nil
remoteMode = .gamepad
menuPressCount = 0
textEntryRequestCount = 0
timeWarning = nil
videoDiagnostics = VideoPipelineSnapshot()
colorState = StreamColorState(
Expand All @@ -539,6 +604,9 @@ final class GFNStreamController: NSObject {
displayHDRSupport: .unknown,
fallbackReason: nil
)
overlayInputPaused = false
controllerTextEntryActive = false
replayInputPaused = false
state = .idle
}

Expand Down Expand Up @@ -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,
Expand All @@ -1975,17 +2045,27 @@ 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
self?.rumbleSink = { sender.applyRumble(controllerId: $0, weak: $1, strong: $2) }
}
// 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 {
Expand Down
Loading