From b1de87215bf167c2ec28246e3b85106a98c04c86 Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Sat, 18 Apr 2026 12:26:06 +0200 Subject: [PATCH 01/11] Support visionOS: input, audio, video Add platform-specific support for tvOS and visionOS across project and runtime code. - Xcode project: add SUPPORTED_PLATFORMS, disable Mac Catalyst, and include tvOS device family ("3,7"). - GFNStreamController: guard tvOS-only remoteMode and toggle; adjust AVAudioSession setup (Bluetooth options on tvOS, simplified handling for other platforms) and use visionOS for microphone permission request. - InputSender: make RemoteInputMode, remote sensitivity, micro-gamepad handling, and related logic tvOS-only (Siri Remote). Ensure tick() processes micro gamepad only on tvOS and keep existing extended gamepad handling for other platforms. - StreamView: gate onExitCommand to tvOS; show an explicit menu button on non-tvOS (visionOS) and show remote-mode toggle only on tvOS. - VideoSurfaceView: use layerClass override on tvOS but add AVSampleBufferDisplayLayer as a sublayer on visionOS (and update layoutSubviews) because layerClass is not supported by the visionOS compositor. These changes separate tvOS-specific Siri Remote behavior and audio routing from visionOS rendering and permission differences to ensure correct runtime behavior on both platforms. --- CloudNow.xcodeproj/project.pbxproj | 8 +++++-- CloudNow/Streaming/GFNStreamController.swift | 16 ++++++++++++-- CloudNow/Streaming/InputSender.swift | 21 ++++++++++++++++--- CloudNow/UI/StreamView.swift | 22 ++++++++++++++++++++ CloudNow/Video/VideoSurfaceView.swift | 19 ++++++++++++++++- 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/CloudNow.xcodeproj/project.pbxproj b/CloudNow.xcodeproj/project.pbxproj index 5d29446..43cefa4 100644 --- a/CloudNow.xcodeproj/project.pbxproj +++ b/CloudNow.xcodeproj/project.pbxproj @@ -293,13 +293,15 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "CloudNow/CloudNow-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 3; + TARGETED_DEVICE_FAMILY = "3,7"; }; name = Debug; }; @@ -328,13 +330,15 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "CloudNow/CloudNow-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 3; + TARGETED_DEVICE_FAMILY = "3,7"; }; name = Release; }; diff --git a/CloudNow/Streaming/GFNStreamController.swift b/CloudNow/Streaming/GFNStreamController.swift index b9a2b47..9cbe0ca 100644 --- a/CloudNow/Streaming/GFNStreamController.swift +++ b/CloudNow/Streaming/GFNStreamController.swift @@ -64,7 +64,9 @@ final class GFNStreamController: NSObject { private var signaling: GFNSignalingClient? private var inputSender: InputSender? private(set) var videoView: VideoSurfaceView? + #if os(tvOS) private(set) var remoteMode: RemoteInputMode = .mouse + #endif private var statsTimer: Timer? private var protocolVersion = 2 private var partialReliableThresholdMs = 300 @@ -126,10 +128,12 @@ final class GFNStreamController: NSObject { // MARK: Input Control + #if os(tvOS) func toggleRemoteMode() { inputSender?.toggleRemoteMode() remoteMode = inputSender?.remoteMode ?? .mouse } + #endif func setInputPaused(_ paused: Bool) { inputSender?.isPaused = paused @@ -165,7 +169,9 @@ final class GFNStreamController: NSObject { videoView?.inputHandler = nil videoView?.menuPressHandler = nil videoView = nil + #if os(tvOS) remoteMode = .mouse + #endif menuPressCount = 0 timeWarning = nil state = .idle @@ -218,14 +224,18 @@ final class GFNStreamController: NSObject { sdp.components(separatedBy: "\r\n").forEach { print(" \($0)") } // Configure audio session for real-time streaming before creating the peer connection. - // .playback + .moviePlayback gives the lowest latency path; allowBluetooth covers - // Bluetooth headsets paired to Apple TV. + // On tvOS, .moviePlayback + Bluetooth options give the lowest latency path. + // On visionOS, spatial audio routing is OS-managed — only setActive is needed. do { + #if os(tvOS) try AVAudioSession.sharedInstance().setCategory( .playback, mode: .moviePlayback, options: [.allowBluetooth, .allowBluetoothA2DP] ) + #else + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + #endif try AVAudioSession.sharedInstance().setActive(true) } catch { print("[Stream] AVAudioSession configuration failed (non-fatal): \(error)") @@ -478,6 +488,8 @@ final class GFNStreamController: NSObject { private func attachMicrophone(to pc: LKRTCPeerConnection) async { #if os(tvOS) let granted = true + #elseif os(visionOS) + let granted = await AVAudioApplication.requestRecordPermission() #else let granted = await withCheckedContinuation { cont in AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) } diff --git a/CloudNow/Streaming/InputSender.swift b/CloudNow/Streaming/InputSender.swift index c71926f..867769a 100644 --- a/CloudNow/Streaming/InputSender.swift +++ b/CloudNow/Streaming/InputSender.swift @@ -40,12 +40,14 @@ private enum GFNInput { static let buttonY: UInt16 = 0x8000 } -// MARK: - Remote Input Mode +// MARK: - Remote Input Mode (tvOS / Siri Remote only) +#if os(tvOS) enum RemoteInputMode { case mouse case gamepad } +#endif // MARK: - Input Event Handler @@ -312,12 +314,13 @@ protocol DataChannelSender: AnyObject { /// Monitors connected GCControllers and keyboard/mouse events; sends encoded input /// over a WebRTC data channel at 60 Hz. final class InputSender { + #if os(tvOS) /// Pixel delta applied per unit of Siri Remote axis deflection per 60 Hz frame. - /// Tune this if the cursor feels too fast or too slow. static let remoteSensitivity: Float = 250.0 /// Siri Remote input mode. Defaults to .mouse so the touchpad drives the cursor. private(set) var remoteMode: RemoteInputMode = .mouse + #endif /// Radial deadzone for analog stick axes (0.0–1.0). Set from StreamSettings.controllerDeadzone. var deadzone: Float = 0.15 @@ -334,9 +337,11 @@ final class InputSender { // Gamepad bitmap: bit i = extended gamepad i is connected (matches official GFN protocol) private var gamepadBitmap: UInt8 = 0 + #if os(tvOS) // Siri Remote state tracking private var lastMicroDpad: (x: Float, y: Float) = (0, 0) private var lastMicroButtonA = false + #endif init(channel: DataChannelSender) { self.channel = channel @@ -370,6 +375,7 @@ final class InputSender { encoder.setProtocolVersion(v) } + #if os(tvOS) // MARK: Remote Mode func toggleRemoteMode() { @@ -377,15 +383,20 @@ final class InputSender { lastMicroDpad = (0, 0) lastMicroButtonA = false } + #endif // MARK: Private — Tick private func tick() { let controllers = GCController.controllers() let extended = controllers.filter { $0.extendedGamepad != nil } - let micro = controllers.filter { $0.extendedGamepad == nil && $0.microGamepad != nil } + #if os(tvOS) + let micro = controllers.filter { $0.extendedGamepad == nil && $0.microGamepad != nil } if extended.isEmpty && micro.isEmpty { return } + #else + if extended.isEmpty { return } + #endif // Extended gamepads — existing XInput encoding for (idx, controller) in extended.prefix(4).enumerated() { @@ -404,12 +415,15 @@ final class InputSender { channel?.sendData(data) } + #if os(tvOS) // Siri Remote — handle only when not paused if !isPaused, let remote = micro.first { handleMicroGamepad(remote) } + #endif } + #if os(tvOS) private func handleMicroGamepad(_ controller: GCController) { guard let pad = controller.microGamepad else { return } @@ -465,6 +479,7 @@ final class InputSender { channel?.sendData(data) } } + #endif // MARK: Private — Controller Notifications diff --git a/CloudNow/UI/StreamView.swift b/CloudNow/UI/StreamView.swift index edce57d..013d897 100644 --- a/CloudNow/UI/StreamView.swift +++ b/CloudNow/UI/StreamView.swift @@ -51,11 +51,13 @@ struct StreamView: View { .onChange(of: streamController.menuPressCount) { _, _ in toggleOverlay() } + #if os(tvOS) .onExitCommand { if streamController.state != .streaming { disconnect() } } + #endif } // MARK: Connecting @@ -138,6 +140,24 @@ struct StreamView: View { .transition(.move(edge: .top).combined(with: .opacity)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + + #if !os(tvOS) + // On visionOS there is no Siri Remote, so provide an explicit menu button. + // Shown only when the overlay is hidden so it doesn't compete with pause menu controls. + if !showOverlay { + Button { + toggleOverlay() + } label: { + Image(systemName: "line.3.horizontal") + .font(.title2.weight(.semibold)) + .foregroundStyle(.white) + .padding(12) + .background(.black.opacity(0.55), in: Circle()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(20) + } + #endif } .animation(.easeInOut(duration: 0.4), value: streamController.timeWarning) .animation(.easeInOut(duration: 0.2), value: showOverlay) @@ -164,6 +184,7 @@ struct StreamView: View { .buttonStyle(.borderedProminent) .tint(.green) + #if os(tvOS) Button { streamController.toggleRemoteMode() } label: { @@ -175,6 +196,7 @@ struct StreamView: View { } .buttonStyle(.bordered) .tint(.white) + #endif Button(role: .destructive) { showExitConfirmation = true diff --git a/CloudNow/Video/VideoSurfaceView.swift b/CloudNow/Video/VideoSurfaceView.swift index 65af917..44ad3c9 100644 --- a/CloudNow/Video/VideoSurfaceView.swift +++ b/CloudNow/Video/VideoSurfaceView.swift @@ -7,14 +7,20 @@ import LiveKitWebRTC // MARK: - VideoSurfaceView /// Full-screen video renderer. -/// Uses AVSampleBufferDisplayLayer as the backing layer (reliable on tvOS). +/// On tvOS, uses AVSampleBufferDisplayLayer as the UIView backing layer (layerClass override). +/// On visionOS, adds AVSampleBufferDisplayLayer as a sublayer instead — layerClass override +/// is not supported by the visionOS compositor. /// LKRTCMTLVideoView (MTKView wrapper) does not render on tvOS — bypassed entirely. /// /// Also acts as first responder for hardware keyboard input and pointer (mouse) /// input, forwarding events to `inputHandler` as GFN protocol packets. final class VideoSurfaceView: UIView { + #if os(tvOS) override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self } private var displayLayer: AVSampleBufferDisplayLayer { layer as! AVSampleBufferDisplayLayer } + #else + private let displayLayer = AVSampleBufferDisplayLayer() + #endif private let renderer = WebRTCFrameRenderer() private var currentTrack: LKRTCVideoTrack? @@ -48,8 +54,19 @@ final class VideoSurfaceView: UIView { setup() } + // On visionOS the displayLayer is a sublayer, so its frame must track the view bounds. + #if !os(tvOS) + override func layoutSubviews() { + super.layoutSubviews() + displayLayer.frame = bounds + } + #endif + private func setup() { backgroundColor = .black + #if !os(tvOS) + layer.addSublayer(displayLayer) + #endif displayLayer.videoGravity = .resizeAspectFill // Set timebase so the layer displays frames at host-clock time (real-time playback) var tb: CMTimebase? From 2d5f4ef20385ba40f5d3973648e517d40f756c48 Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Sat, 18 Apr 2026 12:34:59 +0200 Subject: [PATCH 02/11] Use game.id for toggleFavorite; tvOS buttonStyle Pass game.id to viewModel.toggleFavorite instead of the whole game object and guard .buttonStyle(.card) with #if os(tvOS) so the card button style is only applied on tvOS. Changes applied to CloudNow/UI/LibraryView.swift and CloudNow/UI/StoreView.swift to fix argument type and platform-specific styling. --- CloudNow/UI/LibraryView.swift | 8 +++++++- CloudNow/UI/StoreView.swift | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CloudNow/UI/LibraryView.swift b/CloudNow/UI/LibraryView.swift index 7c35463..58562f3 100644 --- a/CloudNow/UI/LibraryView.swift +++ b/CloudNow/UI/LibraryView.swift @@ -85,10 +85,12 @@ struct LibraryView: View { } label: { GameCardLabel(game: game) } + #if os(tvOS) .buttonStyle(.card) + #endif .contextMenu { Button { - viewModel.toggleFavorite(game) + viewModel.toggleFavorite(game.id) } label: { let isFav = viewModel.favoriteIds.contains(game.id) Label( @@ -184,7 +186,9 @@ struct GameCardView: View { Button(action: onPlay) { GameCardLabel(game: game) } + #if os(tvOS) .buttonStyle(.card) + #endif } } @@ -200,6 +204,8 @@ struct LibraryCardView: View { Button(action: onPlay) { GameCardLabel(game: game) } + #if os(tvOS) .buttonStyle(.card) + #endif } } diff --git a/CloudNow/UI/StoreView.swift b/CloudNow/UI/StoreView.swift index 8c8e484..21b7c35 100644 --- a/CloudNow/UI/StoreView.swift +++ b/CloudNow/UI/StoreView.swift @@ -88,11 +88,13 @@ struct StoreView: View { } label: { StoreCardLabel(game: game) } + #if os(tvOS) .buttonStyle(.card) + #endif .contextMenu { if game.isInLibrary { Button { - viewModel.toggleFavorite(game) + viewModel.toggleFavorite(game.id) } label: { let isFav = viewModel.favoriteIds.contains(game.id) Label( From 71c76d997d02de49d6a66026df4038114edda75d Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Sat, 18 Apr 2026 12:47:47 +0200 Subject: [PATCH 03/11] Add visionOS ImmersiveSpace streaming support Introduce an ImmersiveSpace-based streaming flow for visionOS: add ImmersiveStreamView, register an ImmersiveSpace scene in CloudNowApp, and wire up open/dismiss behavior from MainTabView. Persist and expose an immersion style via AppStorage (gfn.immersionStyle) with runtime syncing and a Settings picker to choose between full and mixed immersion; users can also toggle via the Digital Crown. Add pendingGame/pendingSession fields to GamesViewModel to pass the selected game/session into the ImmersiveSpace and clear state on dismiss. Also make small UI adjustments: use .plain buttonStyle on non-tvOS card buttons and update the exit button to borderedProminent with white foreground for better visibility. --- CloudNow/CloudNowApp.swift | 22 ++++++++++++++++++ CloudNow/UI/GamesViewModel.swift | 6 +++++ CloudNow/UI/ImmersiveStreamView.swift | 32 +++++++++++++++++++++++++++ CloudNow/UI/LibraryView.swift | 6 +++++ CloudNow/UI/MainTabView.swift | 22 ++++++++++++++++++ CloudNow/UI/SettingsView.swift | 15 +++++++++++++ CloudNow/UI/StoreView.swift | 2 ++ CloudNow/UI/StreamView.swift | 3 ++- 8 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 CloudNow/UI/ImmersiveStreamView.swift diff --git a/CloudNow/CloudNowApp.swift b/CloudNow/CloudNowApp.swift index c702f53..6cae751 100644 --- a/CloudNow/CloudNowApp.swift +++ b/CloudNow/CloudNowApp.swift @@ -10,6 +10,12 @@ import SwiftUI @main struct CloudNowApp: App { @State private var authManager = AuthManager() + #if os(visionOS) + /// Drives the ImmersiveSpace immersion level. Seeded from AppStorage on launch; + /// the user can also toggle between .full and .mixed with the Digital Crown at runtime. + @AppStorage("gfn.immersionStyle") private var immersionStyleRaw: String = "full" + @State private var immersionStyle: ImmersionStyle = .full + #endif var body: some Scene { WindowGroup { @@ -22,6 +28,22 @@ struct CloudNowApp: App { } .environment(authManager) .task { await authManager.initialize() } + #if os(visionOS) + .task { + immersionStyle = immersionStyleRaw == "mixed" ? .mixed : .full + } + .onChange(of: immersionStyleRaw) { _, raw in + immersionStyle = raw == "mixed" ? .mixed : .full + } + #endif + } + + #if os(visionOS) + ImmersiveSpace(id: "stream") { + ImmersiveStreamView() + .environment(authManager) } + .immersionStyle(selection: $immersionStyle, in: .full, .mixed) + #endif } } diff --git a/CloudNow/UI/GamesViewModel.swift b/CloudNow/UI/GamesViewModel.swift index 68d182d..40c7ba8 100644 --- a/CloudNow/UI/GamesViewModel.swift +++ b/CloudNow/UI/GamesViewModel.swift @@ -15,6 +15,12 @@ class GamesViewModel { var streamSettings: StreamSettings = StreamSettings() var subscription: SubscriptionInfo? = nil + #if os(visionOS) + /// Set before opening the ImmersiveSpace so the content view can read the pending game. + var pendingGame: GameInfo? = nil + var pendingSession: ActiveSessionInfo? = nil + #endif + private let gamesClient = GamesClient() private let cloudMatchClient = CloudMatchClient() diff --git a/CloudNow/UI/ImmersiveStreamView.swift b/CloudNow/UI/ImmersiveStreamView.swift new file mode 100644 index 0000000..85fb46d --- /dev/null +++ b/CloudNow/UI/ImmersiveStreamView.swift @@ -0,0 +1,32 @@ +#if os(visionOS) +import SwiftUI + +/// Root content view for the streaming ImmersiveSpace scene. +/// Reads the pending game set by MainTabView before the space was opened, +/// hosts StreamView full-screen, and dismisses the space when the stream ends. +struct ImmersiveStreamView: View { + @Environment(GamesViewModel.self) var viewModel + @Environment(AuthManager.self) var authManager + @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace + + var body: some View { + if let game = viewModel.pendingGame { + ZStack { + Color.black.ignoresSafeArea() + StreamView( + game: game, + settings: viewModel.streamSettings, + existingSession: viewModel.pendingSession, + onDismiss: { + viewModel.pendingGame = nil + viewModel.pendingSession = nil + Task { await dismissImmersiveSpace() } + } + ) + .environment(authManager) + .environment(viewModel) + } + } + } +} +#endif diff --git a/CloudNow/UI/LibraryView.swift b/CloudNow/UI/LibraryView.swift index 58562f3..cde0969 100644 --- a/CloudNow/UI/LibraryView.swift +++ b/CloudNow/UI/LibraryView.swift @@ -87,6 +87,8 @@ struct LibraryView: View { } #if os(tvOS) .buttonStyle(.card) + #else + .buttonStyle(.plain) #endif .contextMenu { Button { @@ -188,6 +190,8 @@ struct GameCardView: View { } #if os(tvOS) .buttonStyle(.card) + #else + .buttonStyle(.plain) #endif } } @@ -206,6 +210,8 @@ struct LibraryCardView: View { } #if os(tvOS) .buttonStyle(.card) + #else + .buttonStyle(.plain) #endif } } diff --git a/CloudNow/UI/MainTabView.swift b/CloudNow/UI/MainTabView.swift index 9f1b9de..7164a34 100644 --- a/CloudNow/UI/MainTabView.swift +++ b/CloudNow/UI/MainTabView.swift @@ -5,6 +5,10 @@ struct MainTabView: View { @State private var viewModel = GamesViewModel() @State private var gameToPlay: GameInfo? @State private var sessionToResume: ActiveSessionInfo? = nil + #if os(visionOS) + @Environment(\.openImmersiveSpace) var openImmersiveSpace + @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace + #endif var body: some View { TabView { @@ -39,6 +43,23 @@ struct MainTabView: View { Task { await viewModel.refreshActiveSessions(authManager: authManager) } } } + #if os(visionOS) + // On visionOS, open a full ImmersiveSpace instead of a modal cover. + // pendingGame/pendingSession are read by ImmersiveStreamView inside the space. + .onChange(of: gameToPlay) { _, game in + guard let game else { return } + viewModel.pendingGame = game + viewModel.pendingSession = sessionToResume + Task { await openImmersiveSpace(id: "stream") } + } + // When ImmersiveStreamView clears pendingGame on dismiss, sync gameToPlay back to nil. + .onChange(of: viewModel.pendingGame) { _, pending in + if pending == nil { + gameToPlay = nil + sessionToResume = nil + } + } + #else .fullScreenCover(item: $gameToPlay) { game in StreamView( game: game, @@ -52,5 +73,6 @@ struct MainTabView: View { .environment(authManager) .environment(viewModel) } + #endif } } diff --git a/CloudNow/UI/SettingsView.swift b/CloudNow/UI/SettingsView.swift index b2a14ac..fcf81b0 100644 --- a/CloudNow/UI/SettingsView.swift +++ b/CloudNow/UI/SettingsView.swift @@ -5,6 +5,9 @@ struct SettingsView: View { @Environment(GamesViewModel.self) var viewModel @State private var showZonePicker = false + #if os(visionOS) + @AppStorage("gfn.immersionStyle") private var immersionStyleRaw: String = "full" + #endif var body: some View { @Bindable var vm = viewModel @@ -100,6 +103,18 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + #if os(visionOS) + Section("Display") { + Picker("Immersion Style", selection: $immersionStyleRaw) { + Text("Full (Cinema)").tag("full") + Text("Mixed (Floating)").tag("mixed") + } + Text("Full replaces your surroundings with a cinema-black background. Mixed keeps the game floating in your real environment. You can also switch between them with the Digital Crown while streaming.") + .font(.caption) + .foregroundStyle(.secondary) + } + #endif + Section("Controller") { LabeledContent("Deadzone") { Text("\(Int(vm.streamSettings.controllerDeadzone * 100))%") diff --git a/CloudNow/UI/StoreView.swift b/CloudNow/UI/StoreView.swift index 21b7c35..f95e234 100644 --- a/CloudNow/UI/StoreView.swift +++ b/CloudNow/UI/StoreView.swift @@ -90,6 +90,8 @@ struct StoreView: View { } #if os(tvOS) .buttonStyle(.card) + #else + .buttonStyle(.plain) #endif .contextMenu { if game.isInLibrary { diff --git a/CloudNow/UI/StreamView.swift b/CloudNow/UI/StreamView.swift index 013d897..094f34c 100644 --- a/CloudNow/UI/StreamView.swift +++ b/CloudNow/UI/StreamView.swift @@ -203,8 +203,9 @@ struct StreamView: View { } label: { Label("Exit Game", systemImage: "xmark.circle") .frame(minWidth: 180) + .foregroundStyle(.white) } - .buttonStyle(.bordered) + .buttonStyle(.borderedProminent) .tint(.red) } From ee3b365d27f8b85d0a69a886b2baa4da73e4ccaf Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Sat, 18 Apr 2026 12:58:13 +0200 Subject: [PATCH 04/11] Inject GamesViewModel into app environment Move GamesViewModel ownership to the App so a single instance can be injected into WindowGroup and ImmersiveSpace (visionOS) and shared across platforms. MainTabView now reads GamesViewModel from the environment instead of owning its own state. Refactor play flow into a private play(_:, session:) helper that sets sessionToResume and gameToPlay, update callers, and simplify active-session lookup. Also wire .environment(viewModel) at the App level and keep existing load/refresh/save tasks intact. --- CloudNow/CloudNowApp.swift | 5 +++++ CloudNow/UI/MainTabView.swift | 40 +++++++++-------------------------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/CloudNow/CloudNowApp.swift b/CloudNow/CloudNowApp.swift index 6cae751..a898298 100644 --- a/CloudNow/CloudNowApp.swift +++ b/CloudNow/CloudNowApp.swift @@ -10,6 +10,9 @@ import SwiftUI @main struct CloudNowApp: App { @State private var authManager = AuthManager() + /// Owned here so it can be injected into both WindowGroup and ImmersiveSpace (visionOS), + /// sharing the same instance (pendingGame visibility). On tvOS this is the single owner too. + @State private var viewModel = GamesViewModel() #if os(visionOS) /// Drives the ImmersiveSpace immersion level. Seeded from AppStorage on launch; /// the user can also toggle between .full and .mixed with the Digital Crown at runtime. @@ -27,6 +30,7 @@ struct CloudNowApp: App { } } .environment(authManager) + .environment(viewModel) .task { await authManager.initialize() } #if os(visionOS) .task { @@ -42,6 +46,7 @@ struct CloudNowApp: App { ImmersiveSpace(id: "stream") { ImmersiveStreamView() .environment(authManager) + .environment(viewModel) } .immersionStyle(selection: $immersionStyle, in: .full, .mixed) #endif diff --git a/CloudNow/UI/MainTabView.swift b/CloudNow/UI/MainTabView.swift index 7164a34..85f41b2 100644 --- a/CloudNow/UI/MainTabView.swift +++ b/CloudNow/UI/MainTabView.swift @@ -2,64 +2,40 @@ import SwiftUI struct MainTabView: View { @Environment(AuthManager.self) var authManager - @State private var viewModel = GamesViewModel() + @Environment(GamesViewModel.self) var viewModel @State private var gameToPlay: GameInfo? @State private var sessionToResume: ActiveSessionInfo? = nil - #if os(visionOS) - @Environment(\.openImmersiveSpace) var openImmersiveSpace - @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace - #endif var body: some View { TabView { Tab("Home", systemImage: "house.fill") { HomeView(onPlay: { game in - // If this game has an active session, set it up for resume - sessionToResume = viewModel.activeSessions.first { session in + let session = viewModel.activeSessions.first { session in game.variants.contains { v in guard let appId = v.appId, let sessionAppId = session.appId else { return false } return appId == sessionAppId } } - gameToPlay = game + play(game, session: session) }) } Tab("Library", systemImage: "books.vertical.fill") { - LibraryView(games: viewModel.libraryGames, onPlay: { gameToPlay = $0 }) + LibraryView(games: viewModel.libraryGames, onPlay: { play($0) }) } Tab("Store", systemImage: "bag.fill") { - StoreView(games: viewModel.mainGames, onPlay: { gameToPlay = $0 }) + StoreView(games: viewModel.mainGames, onPlay: { play($0) }) } Tab("Settings", systemImage: "gearshape.fill") { SettingsView() } } - .environment(viewModel) .task { await viewModel.load(authManager: authManager) } .onChange(of: viewModel.streamSettings) { viewModel.saveSettings() } .onChange(of: gameToPlay) { _, new in - // Refresh active sessions when the user exits a game if new == nil { Task { await viewModel.refreshActiveSessions(authManager: authManager) } } } - #if os(visionOS) - // On visionOS, open a full ImmersiveSpace instead of a modal cover. - // pendingGame/pendingSession are read by ImmersiveStreamView inside the space. - .onChange(of: gameToPlay) { _, game in - guard let game else { return } - viewModel.pendingGame = game - viewModel.pendingSession = sessionToResume - Task { await openImmersiveSpace(id: "stream") } - } - // When ImmersiveStreamView clears pendingGame on dismiss, sync gameToPlay back to nil. - .onChange(of: viewModel.pendingGame) { _, pending in - if pending == nil { - gameToPlay = nil - sessionToResume = nil - } - } - #else .fullScreenCover(item: $gameToPlay) { game in StreamView( game: game, @@ -73,6 +49,10 @@ struct MainTabView: View { .environment(authManager) .environment(viewModel) } - #endif + } + + private func play(_ game: GameInfo, session: ActiveSessionInfo? = nil) { + sessionToResume = session + gameToPlay = game } } From b456d4261fc96375da396169167ace7ed0b55e38 Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Sat, 18 Apr 2026 12:26:06 +0200 Subject: [PATCH 05/11] Support visionOS: input, audio, video Add platform-specific support for tvOS and visionOS across project and runtime code. - Xcode project: add SUPPORTED_PLATFORMS, disable Mac Catalyst, and include tvOS device family ("3,7"). - GFNStreamController: guard tvOS-only remoteMode and toggle; adjust AVAudioSession setup (Bluetooth options on tvOS, simplified handling for other platforms) and use visionOS for microphone permission request. - InputSender: make RemoteInputMode, remote sensitivity, micro-gamepad handling, and related logic tvOS-only (Siri Remote). Ensure tick() processes micro gamepad only on tvOS and keep existing extended gamepad handling for other platforms. - StreamView: gate onExitCommand to tvOS; show an explicit menu button on non-tvOS (visionOS) and show remote-mode toggle only on tvOS. - VideoSurfaceView: use layerClass override on tvOS but add AVSampleBufferDisplayLayer as a sublayer on visionOS (and update layoutSubviews) because layerClass is not supported by the visionOS compositor. These changes separate tvOS-specific Siri Remote behavior and audio routing from visionOS rendering and permission differences to ensure correct runtime behavior on both platforms. --- CloudNow.xcodeproj/project.pbxproj | 8 ++- CloudNow/Streaming/GFNStreamController.swift | 16 +++++- CloudNow/Streaming/InputSender.swift | 51 +++++++++++++++++--- CloudNow/UI/StreamView.swift | 22 +++++++++ CloudNow/Video/VideoSurfaceView.swift | 19 +++++++- 5 files changed, 105 insertions(+), 11 deletions(-) diff --git a/CloudNow.xcodeproj/project.pbxproj b/CloudNow.xcodeproj/project.pbxproj index 861de92..b2aba0e 100644 --- a/CloudNow.xcodeproj/project.pbxproj +++ b/CloudNow.xcodeproj/project.pbxproj @@ -295,13 +295,15 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "CloudNow/CloudNow-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 3; + TARGETED_DEVICE_FAMILY = "3,7"; }; name = Debug; }; @@ -332,13 +334,15 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "CloudNow/CloudNow-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 3; + TARGETED_DEVICE_FAMILY = "3,7"; }; name = Release; }; diff --git a/CloudNow/Streaming/GFNStreamController.swift b/CloudNow/Streaming/GFNStreamController.swift index 2319709..d317bf4 100644 --- a/CloudNow/Streaming/GFNStreamController.swift +++ b/CloudNow/Streaming/GFNStreamController.swift @@ -64,7 +64,9 @@ final class GFNStreamController: NSObject { private var signaling: GFNSignalingClient? private var inputSender: InputSender? private(set) var videoView: VideoSurfaceView? + #if os(tvOS) private(set) var remoteMode: RemoteInputMode = .mouse + #endif private var statsTimer: Timer? private var protocolVersion = 2 private var partialReliableThresholdMs = 300 @@ -126,11 +128,13 @@ final class GFNStreamController: NSObject { // MARK: Input Control + #if os(tvOS) func toggleRemoteMode() { inputSender?.toggleRemoteMode() remoteMode = inputSender?.remoteMode ?? .mouse videoView?.gamepadModeActive = (remoteMode == .gamepad || remoteMode == .dualsense) } + #endif func setInputPaused(_ paused: Bool) { inputSender?.isPaused = paused @@ -166,7 +170,9 @@ final class GFNStreamController: NSObject { videoView?.inputHandler = nil videoView?.menuPressHandler = nil videoView = nil + #if os(tvOS) remoteMode = .mouse + #endif menuPressCount = 0 timeWarning = nil state = .idle @@ -220,14 +226,18 @@ final class GFNStreamController: NSObject { sdp.components(separatedBy: "\r\n").forEach { print(" \($0)") } // Configure audio session for real-time streaming before creating the peer connection. - // .playback + .moviePlayback gives the lowest latency path; allowBluetooth covers - // Bluetooth headsets paired to Apple TV. + // On tvOS, .moviePlayback + Bluetooth options give the lowest latency path. + // On visionOS, spatial audio routing is OS-managed — only setActive is needed. do { + #if os(tvOS) try AVAudioSession.sharedInstance().setCategory( .playback, mode: .moviePlayback, options: [.allowBluetooth, .allowBluetoothA2DP] ) + #else + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + #endif try AVAudioSession.sharedInstance().setActive(true) } catch { print("[Stream] AVAudioSession configuration failed (non-fatal): \(error)") @@ -480,6 +490,8 @@ final class GFNStreamController: NSObject { private func attachMicrophone(to pc: LKRTCPeerConnection) async { #if os(tvOS) let granted = true + #elseif os(visionOS) + let granted = await AVAudioApplication.requestRecordPermission() #else let granted = await withCheckedContinuation { cont in AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) } diff --git a/CloudNow/Streaming/InputSender.swift b/CloudNow/Streaming/InputSender.swift index 49a92e5..f3cffed 100644 --- a/CloudNow/Streaming/InputSender.swift +++ b/CloudNow/Streaming/InputSender.swift @@ -40,13 +40,15 @@ private enum GFNInput { static let buttonY: UInt16 = 0x8000 } -// MARK: - Remote Input Mode +// MARK: - Remote Input Mode (tvOS / Siri Remote only) +#if os(tvOS) enum RemoteInputMode: String, Codable, Equatable { case mouse case gamepad case dualsense } +#endif // MARK: - Input Event Handler @@ -329,12 +331,13 @@ protocol DataChannelSender: AnyObject { /// Monitors connected GCControllers and keyboard/mouse events; sends encoded input /// over a WebRTC data channel at 60 Hz. final class InputSender { + #if os(tvOS) /// Pixel delta applied per unit of Siri Remote axis deflection per 60 Hz frame. - /// Tune this if the cursor feels too fast or too slow. static let remoteSensitivity: Float = 250.0 /// Siri Remote input mode. Defaults to .mouse so the touchpad drives the cursor. - var remoteMode: RemoteInputMode = .mouse + private(set) var remoteMode: RemoteInputMode = .mouse + #endif /// Radial deadzone for analog stick axes (0.0–1.0). Set from StreamSettings.controllerDeadzone. var deadzone: Float = 0.15 @@ -348,8 +351,10 @@ final class InputSender { /// Which controller button triggers the overlay on long-press. Matches StreamSettings.overlayTriggerButton. var overlayTriggerButton: OverlayTriggerButton = .start + #if os(tvOS) /// Called when remoteMode changes due to controller connect/disconnect auto-switching. var onRemoteModeChanged: ((RemoteInputMode) -> Void)? + #endif private weak var channel: DataChannelSender? let encoder = InputEncoder() @@ -360,9 +365,11 @@ final class InputSender { // Gamepad bitmap: bit i = extended gamepad i is connected (matches official GFN protocol) private var gamepadBitmap: UInt8 = 0 + #if os(tvOS) // Siri Remote state tracking private var lastMicroDpad: (x: Float, y: Float) = (0, 0) private var lastMicroButtonA = false + #endif // DualSense touchpad state tracking private var lastDualSenseTouchpad: (x: Float, y: Float) = (0, 0) @@ -405,6 +412,7 @@ final class InputSender { encoder.setProtocolVersion(v) } + #if os(tvOS) // MARK: Remote Mode func toggleRemoteMode() { @@ -430,16 +438,22 @@ final class InputSender { } } } + #endif // MARK: Private — Tick private func tick() { let controllers = GCController.controllers() let extended = controllers.filter { $0.extendedGamepad != nil } - let micro = controllers.filter { $0.extendedGamepad == nil && $0.microGamepad != nil } + #if os(tvOS) + let micro = controllers.filter { $0.extendedGamepad == nil && $0.microGamepad != nil } if extended.isEmpty && micro.isEmpty { return } + #else + if extended.isEmpty { return } + #endif + #if os(tvOS) if remoteMode == .gamepad || remoteMode == .dualsense { // Gamepad/DualSense mode: extended controller owns the game; remote is suppressed when // a real controller is present (otherwise the remote's empty state overwrites it). @@ -503,8 +517,26 @@ final class InputSender { handleMicroGamepad(remote) } } + #else + for (idx, controller) in extended.prefix(4).enumerated() { + let (btns, lt, rt, lx, ly, rx, ry) = mapGCControllerToXInput(controller, deadzone: deadzone) + let data = encoder.encodeGamepad( + controllerId: idx, + buttons: btns, + leftTrigger: lt, + rightTrigger: rt, + leftStickX: lx, + leftStickY: ly, + rightStickX: rx, + rightStickY: ry, + gamepadBitmap: gamepadBitmap + ) + channel?.sendData(data) + } + #endif } + #if os(tvOS) private func handleMicroGamepad(_ controller: GCController) { guard let pad = controller.microGamepad else { return } @@ -587,6 +619,7 @@ final class InputSender { sendMouseButton(down: clicked, button: 1) } } + #endif // MARK: Private — Controller Notifications @@ -624,11 +657,13 @@ final class InputSender { GCController.startWirelessControllerDiscovery() // Seed gamepadBitmap for controllers already connected before InputSender started. - // System gesture ownership is only claimed when in gamepad mode (starts as .mouse). for controller in GCController.controllers() where controller.extendedGamepad != nil { - if remoteMode == .gamepad { claimControllerInput(controller) } let idx = GCController.controllers().firstIndex(where: { $0 === controller }) ?? 0 gamepadBitmap |= (1 << UInt8(idx & 3)) + #if os(tvOS) + // System gesture ownership is only claimed when in gamepad mode (starts as .mouse). + if remoteMode == .gamepad { claimControllerInput(controller) } + #endif } // Wire up any mice already connected at start time @@ -713,6 +748,7 @@ final class InputSender { guard controller.extendedGamepad != nil else { return } let idx = GCController.controllers().firstIndex(where: { $0 === controller }) ?? 0 gamepadBitmap |= (1 << UInt8(idx & 3)) + #if os(tvOS) // Auto-switch to gamepad mode when a real controller connects. if remoteMode == .mouse { remoteMode = .gamepad @@ -721,6 +757,7 @@ final class InputSender { } else { claimControllerInput(controller) } + #endif let data = encoder.encodeGamepad( controllerId: idx, buttons: 0, leftTrigger: 0, rightTrigger: 0, leftStickX: 0, leftStickY: 0, rightStickX: 0, rightStickY: 0, @@ -733,12 +770,14 @@ final class InputSender { guard controller.extendedGamepad != nil else { return } let idx = GCController.controllers().firstIndex(where: { $0 === controller }) ?? 0 gamepadBitmap &= ~(1 << UInt8(idx & 3)) + #if os(tvOS) // Revert to mouse mode when the last controller disconnects. if gamepadBitmap == 0 && remoteMode != .mouse { remoteMode = .mouse applyRemoteMode() onRemoteModeChanged?(remoteMode) } + #endif let data = encoder.encodeGamepad( controllerId: idx, buttons: 0, leftTrigger: 0, rightTrigger: 0, leftStickX: 0, leftStickY: 0, rightStickX: 0, rightStickY: 0, diff --git a/CloudNow/UI/StreamView.swift b/CloudNow/UI/StreamView.swift index 5b099a9..15ebaa6 100644 --- a/CloudNow/UI/StreamView.swift +++ b/CloudNow/UI/StreamView.swift @@ -55,11 +55,13 @@ struct StreamView: View { .onChange(of: streamController.menuPressCount) { _, _ in toggleOverlay() } + #if os(tvOS) .onExitCommand { if streamController.state != .streaming { disconnect() } } + #endif } // MARK: Connecting @@ -142,6 +144,24 @@ struct StreamView: View { .transition(.move(edge: .top).combined(with: .opacity)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + + #if !os(tvOS) + // On visionOS there is no Siri Remote, so provide an explicit menu button. + // Shown only when the overlay is hidden so it doesn't compete with pause menu controls. + if !showOverlay { + Button { + toggleOverlay() + } label: { + Image(systemName: "line.3.horizontal") + .font(.title2.weight(.semibold)) + .foregroundStyle(.white) + .padding(12) + .background(.black.opacity(0.55), in: Circle()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(20) + } + #endif } .animation(.easeInOut(duration: 0.4), value: streamController.timeWarning) .animation(.easeInOut(duration: 0.2), value: showOverlay) @@ -173,6 +193,7 @@ struct StreamView: View { .buttonStyle(.borderedProminent) .tint(.green) + #if os(tvOS) Button { streamController.toggleRemoteMode() } label: { @@ -190,6 +211,7 @@ struct StreamView: View { } .buttonStyle(.borderedProminent) .tint(.white) + #endif Button(role: .destructive) { showExitConfirmation = true diff --git a/CloudNow/Video/VideoSurfaceView.swift b/CloudNow/Video/VideoSurfaceView.swift index ad3a32a..66c5e25 100644 --- a/CloudNow/Video/VideoSurfaceView.swift +++ b/CloudNow/Video/VideoSurfaceView.swift @@ -7,14 +7,20 @@ import LiveKitWebRTC // MARK: - VideoSurfaceView /// Full-screen video renderer. -/// Uses AVSampleBufferDisplayLayer as the backing layer (reliable on tvOS). +/// On tvOS, uses AVSampleBufferDisplayLayer as the UIView backing layer (layerClass override). +/// On visionOS, adds AVSampleBufferDisplayLayer as a sublayer instead — layerClass override +/// is not supported by the visionOS compositor. /// LKRTCMTLVideoView (MTKView wrapper) does not render on tvOS — bypassed entirely. /// /// Also acts as first responder for hardware keyboard input and pointer (mouse) /// input, forwarding events to `inputHandler` as GFN protocol packets. final class VideoSurfaceView: UIView { + #if os(tvOS) override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self } private var displayLayer: AVSampleBufferDisplayLayer { layer as! AVSampleBufferDisplayLayer } + #else + private let displayLayer = AVSampleBufferDisplayLayer() + #endif private let renderer = WebRTCFrameRenderer() private var currentTrack: LKRTCVideoTrack? @@ -52,8 +58,19 @@ final class VideoSurfaceView: UIView { setup() } + // On visionOS the displayLayer is a sublayer, so its frame must track the view bounds. + #if !os(tvOS) + override func layoutSubviews() { + super.layoutSubviews() + displayLayer.frame = bounds + } + #endif + private func setup() { backgroundColor = .black + #if !os(tvOS) + layer.addSublayer(displayLayer) + #endif displayLayer.videoGravity = .resizeAspectFill // Set timebase so the layer displays frames at host-clock time (real-time playback) var tb: CMTimebase? From 19dca233ab442817d795b6606754d8e68dcad355 Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Sat, 18 Apr 2026 12:34:59 +0200 Subject: [PATCH 06/11] Use game.id for toggleFavorite; tvOS buttonStyle Pass game.id to viewModel.toggleFavorite instead of the whole game object and guard .buttonStyle(.card) with #if os(tvOS) so the card button style is only applied on tvOS. Changes applied to CloudNow/UI/LibraryView.swift and CloudNow/UI/StoreView.swift to fix argument type and platform-specific styling. --- CloudNow/UI/LibraryView.swift | 6 ++++++ CloudNow/UI/StoreView.swift | 2 ++ 2 files changed, 8 insertions(+) diff --git a/CloudNow/UI/LibraryView.swift b/CloudNow/UI/LibraryView.swift index c77f415..501f655 100644 --- a/CloudNow/UI/LibraryView.swift +++ b/CloudNow/UI/LibraryView.swift @@ -84,7 +84,9 @@ struct LibraryView: View { GameCardLabel(game: game) } .aspectRatio(2/3, contentMode: .fit) + #if os(tvOS) .buttonStyle(.card) + #endif .contextMenu { Button { viewModel.toggleFavorite(game.id) @@ -214,7 +216,9 @@ struct GameCardView: View { Button(action: onPlay) { GameCardLabel(game: game) } + #if os(tvOS) .buttonStyle(.card) + #endif } } @@ -230,6 +234,8 @@ struct LibraryCardView: View { Button(action: onPlay) { GameCardLabel(game: game) } + #if os(tvOS) .buttonStyle(.card) + #endif } } diff --git a/CloudNow/UI/StoreView.swift b/CloudNow/UI/StoreView.swift index 33e071f..676fc6e 100644 --- a/CloudNow/UI/StoreView.swift +++ b/CloudNow/UI/StoreView.swift @@ -90,7 +90,9 @@ struct StoreView: View { StoreCardLabel(game: game) } .aspectRatio(2/3, contentMode: .fit) + #if os(tvOS) .buttonStyle(.card) + #endif .contextMenu { if game.isInLibrary { Button { From a576985058774947435a0abee052c28905e04c8b Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Sat, 18 Apr 2026 12:47:47 +0200 Subject: [PATCH 07/11] Add visionOS ImmersiveSpace streaming support Introduce an ImmersiveSpace-based streaming flow for visionOS: add ImmersiveStreamView, register an ImmersiveSpace scene in CloudNowApp, and wire up open/dismiss behavior from MainTabView. Persist and expose an immersion style via AppStorage (gfn.immersionStyle) with runtime syncing and a Settings picker to choose between full and mixed immersion; users can also toggle via the Digital Crown. Add pendingGame/pendingSession fields to GamesViewModel to pass the selected game/session into the ImmersiveSpace and clear state on dismiss. Also make small UI adjustments: use .plain buttonStyle on non-tvOS card buttons and update the exit button to borderedProminent with white foreground for better visibility. --- CloudNow/CloudNowApp.swift | 22 ++++++++++++++++++ CloudNow/UI/GamesViewModel.swift | 6 +++++ CloudNow/UI/ImmersiveStreamView.swift | 32 +++++++++++++++++++++++++++ CloudNow/UI/LibraryView.swift | 6 +++++ CloudNow/UI/MainTabView.swift | 22 ++++++++++++++++++ CloudNow/UI/SettingsView.swift | 15 +++++++++++++ CloudNow/UI/StoreView.swift | 2 ++ CloudNow/UI/StreamView.swift | 3 ++- 8 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 CloudNow/UI/ImmersiveStreamView.swift diff --git a/CloudNow/CloudNowApp.swift b/CloudNow/CloudNowApp.swift index 9e471de..ac50c18 100644 --- a/CloudNow/CloudNowApp.swift +++ b/CloudNow/CloudNowApp.swift @@ -11,6 +11,12 @@ import SwiftUI @main struct CloudNowApp: App { @State private var authManager = AuthManager() + #if os(visionOS) + /// Drives the ImmersiveSpace immersion level. Seeded from AppStorage on launch; + /// the user can also toggle between .full and .mixed with the Digital Crown at runtime. + @AppStorage("gfn.immersionStyle") private var immersionStyleRaw: String = "full" + @State private var immersionStyle: ImmersionStyle = .full + #endif init() { URLCache.shared = URLCache( @@ -31,7 +37,23 @@ struct CloudNowApp: App { .environment(authManager) .onAppear { registerBGTasks() } .task { await authManager.initialize() } + #if os(visionOS) + .task { + immersionStyle = immersionStyleRaw == "mixed" ? .mixed : .full + } + .onChange(of: immersionStyleRaw) { _, raw in + immersionStyle = raw == "mixed" ? .mixed : .full + } + #endif + } + + #if os(visionOS) + ImmersiveSpace(id: "stream") { + ImmersiveStreamView() + .environment(authManager) } + .immersionStyle(selection: $immersionStyle, in: .full, .mixed) + #endif } private func registerBGTasks() { diff --git a/CloudNow/UI/GamesViewModel.swift b/CloudNow/UI/GamesViewModel.swift index d1e03e9..e4cf232 100644 --- a/CloudNow/UI/GamesViewModel.swift +++ b/CloudNow/UI/GamesViewModel.swift @@ -32,6 +32,12 @@ class GamesViewModel { /// Session the user left without ending — available to resume for ~2 minutes. var resumableSession: ResumableSession? = nil + #if os(visionOS) + /// Set before opening the ImmersiveSpace so the content view can read the pending game. + var pendingGame: GameInfo? = nil + var pendingSession: ActiveSessionInfo? = nil + #endif + private let gamesClient = GamesClient() private let cloudMatchClient = CloudMatchClient() diff --git a/CloudNow/UI/ImmersiveStreamView.swift b/CloudNow/UI/ImmersiveStreamView.swift new file mode 100644 index 0000000..85fb46d --- /dev/null +++ b/CloudNow/UI/ImmersiveStreamView.swift @@ -0,0 +1,32 @@ +#if os(visionOS) +import SwiftUI + +/// Root content view for the streaming ImmersiveSpace scene. +/// Reads the pending game set by MainTabView before the space was opened, +/// hosts StreamView full-screen, and dismisses the space when the stream ends. +struct ImmersiveStreamView: View { + @Environment(GamesViewModel.self) var viewModel + @Environment(AuthManager.self) var authManager + @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace + + var body: some View { + if let game = viewModel.pendingGame { + ZStack { + Color.black.ignoresSafeArea() + StreamView( + game: game, + settings: viewModel.streamSettings, + existingSession: viewModel.pendingSession, + onDismiss: { + viewModel.pendingGame = nil + viewModel.pendingSession = nil + Task { await dismissImmersiveSpace() } + } + ) + .environment(authManager) + .environment(viewModel) + } + } + } +} +#endif diff --git a/CloudNow/UI/LibraryView.swift b/CloudNow/UI/LibraryView.swift index 501f655..3f458de 100644 --- a/CloudNow/UI/LibraryView.swift +++ b/CloudNow/UI/LibraryView.swift @@ -86,6 +86,8 @@ struct LibraryView: View { .aspectRatio(2/3, contentMode: .fit) #if os(tvOS) .buttonStyle(.card) + #else + .buttonStyle(.plain) #endif .contextMenu { Button { @@ -218,6 +220,8 @@ struct GameCardView: View { } #if os(tvOS) .buttonStyle(.card) + #else + .buttonStyle(.plain) #endif } } @@ -236,6 +240,8 @@ struct LibraryCardView: View { } #if os(tvOS) .buttonStyle(.card) + #else + .buttonStyle(.plain) #endif } } diff --git a/CloudNow/UI/MainTabView.swift b/CloudNow/UI/MainTabView.swift index dc384eb..6265a2a 100644 --- a/CloudNow/UI/MainTabView.swift +++ b/CloudNow/UI/MainTabView.swift @@ -6,6 +6,10 @@ struct MainTabView: View { @State private var gameToPlay: GameInfo? @State private var sessionToResume: ActiveSessionInfo? = nil @State private var directSessionToResume: SessionInfo? = nil + #if os(visionOS) + @Environment(\.openImmersiveSpace) var openImmersiveSpace + @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace + #endif var body: some View { TabView { @@ -47,6 +51,23 @@ struct MainTabView: View { Task { await viewModel.refreshActiveSessions(authManager: authManager) } } } + #if os(visionOS) + // On visionOS, open a full ImmersiveSpace instead of a modal cover. + // pendingGame/pendingSession are read by ImmersiveStreamView inside the space. + .onChange(of: gameToPlay) { _, game in + guard let game else { return } + viewModel.pendingGame = game + viewModel.pendingSession = sessionToResume + Task { await openImmersiveSpace(id: "stream") } + } + // When ImmersiveStreamView clears pendingGame on dismiss, sync gameToPlay back to nil. + .onChange(of: viewModel.pendingGame) { _, pending in + if pending == nil { + gameToPlay = nil + sessionToResume = nil + } + } + #else .fullScreenCover(item: $gameToPlay) { game in StreamView( game: game, @@ -68,5 +89,6 @@ struct MainTabView: View { .environment(authManager) .environment(viewModel) } + #endif } } diff --git a/CloudNow/UI/SettingsView.swift b/CloudNow/UI/SettingsView.swift index 7ac97d7..d4f918f 100644 --- a/CloudNow/UI/SettingsView.swift +++ b/CloudNow/UI/SettingsView.swift @@ -5,6 +5,9 @@ struct SettingsView: View { @Environment(GamesViewModel.self) var viewModel @State private var showZonePicker = false + #if os(visionOS) + @AppStorage("gfn.immersionStyle") private var immersionStyleRaw: String = "full" + #endif var body: some View { @Bindable var vm = viewModel @@ -165,6 +168,18 @@ struct SettingsView: View { } } + #if os(visionOS) + Section("Display") { + Picker("Immersion Style", selection: $immersionStyleRaw) { + Text("Full (Cinema)").tag("full") + Text("Mixed (Floating)").tag("mixed") + } + Text("Full replaces your surroundings with a cinema-black background. Mixed keeps the game floating in your real environment. You can also switch between them with the Digital Crown while streaming.") + .font(.caption) + .foregroundStyle(.secondary) + } + #endif + Section("Controller") { LabeledContent { HStack(spacing: 16) { diff --git a/CloudNow/UI/StoreView.swift b/CloudNow/UI/StoreView.swift index 676fc6e..666e894 100644 --- a/CloudNow/UI/StoreView.swift +++ b/CloudNow/UI/StoreView.swift @@ -92,6 +92,8 @@ struct StoreView: View { .aspectRatio(2/3, contentMode: .fit) #if os(tvOS) .buttonStyle(.card) + #else + .buttonStyle(.plain) #endif .contextMenu { if game.isInLibrary { diff --git a/CloudNow/UI/StreamView.swift b/CloudNow/UI/StreamView.swift index 15ebaa6..b9e5345 100644 --- a/CloudNow/UI/StreamView.swift +++ b/CloudNow/UI/StreamView.swift @@ -218,8 +218,9 @@ struct StreamView: View { } label: { Label("End Session", systemImage: "xmark.circle") .frame(minWidth: 180) + .foregroundStyle(.white) } - .buttonStyle(.bordered) + .buttonStyle(.borderedProminent) .tint(.red) } From a476d17b5a13adb7c6bc6b2808b3d8bcbe18163e Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Sat, 18 Apr 2026 12:58:13 +0200 Subject: [PATCH 08/11] Inject GamesViewModel into app environment Move GamesViewModel ownership to the App so a single instance can be injected into WindowGroup and ImmersiveSpace (visionOS) and shared across platforms. MainTabView now reads GamesViewModel from the environment instead of owning its own state. Refactor play flow into a private play(_:, session:) helper that sets sessionToResume and gameToPlay, update callers, and simplify active-session lookup. Also wire .environment(viewModel) at the App level and keep existing load/refresh/save tasks intact. --- CloudNow/CloudNowApp.swift | 11 +++++++++++ CloudNow/UI/MainTabView.swift | 17 +++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CloudNow/CloudNowApp.swift b/CloudNow/CloudNowApp.swift index ac50c18..39d17f7 100644 --- a/CloudNow/CloudNowApp.swift +++ b/CloudNow/CloudNowApp.swift @@ -5,12 +5,17 @@ // Created by Owen Selles on 11/04/2026. // +#if os(tvOS) import BackgroundTasks +#endif import SwiftUI @main struct CloudNowApp: App { @State private var authManager = AuthManager() + /// Owned here so it can be injected into both WindowGroup and ImmersiveSpace (visionOS), + /// sharing the same instance (pendingGame visibility). On tvOS this is the single owner too. + @State private var viewModel = GamesViewModel() #if os(visionOS) /// Drives the ImmersiveSpace immersion level. Seeded from AppStorage on launch; /// the user can also toggle between .full and .mixed with the Digital Crown at runtime. @@ -35,7 +40,10 @@ struct CloudNowApp: App { } } .environment(authManager) + .environment(viewModel) + #if os(tvOS) .onAppear { registerBGTasks() } + #endif .task { await authManager.initialize() } #if os(visionOS) .task { @@ -51,11 +59,13 @@ struct CloudNowApp: App { ImmersiveSpace(id: "stream") { ImmersiveStreamView() .environment(authManager) + .environment(viewModel) } .immersionStyle(selection: $immersionStyle, in: .full, .mixed) #endif } + #if os(tvOS) private func registerBGTasks() { BGTaskScheduler.shared.register( forTaskWithIdentifier: "com.owenselles.CloudNow.tokenRefresh", @@ -68,4 +78,5 @@ struct CloudNowApp: App { } } } + #endif } diff --git a/CloudNow/UI/MainTabView.swift b/CloudNow/UI/MainTabView.swift index 6265a2a..bace458 100644 --- a/CloudNow/UI/MainTabView.swift +++ b/CloudNow/UI/MainTabView.swift @@ -2,7 +2,7 @@ import SwiftUI struct MainTabView: View { @Environment(AuthManager.self) var authManager - @State private var viewModel = GamesViewModel() + @Environment(GamesViewModel.self) var viewModel @State private var gameToPlay: GameInfo? @State private var sessionToResume: ActiveSessionInfo? = nil @State private var directSessionToResume: SessionInfo? = nil @@ -23,7 +23,7 @@ struct MainTabView: View { return appId == sessionAppId } } - gameToPlay = game + play(game, session: sessionToResume) }, onResume: { rs in directSessionToResume = rs.session @@ -33,16 +33,15 @@ struct MainTabView: View { ) } Tab("Library", systemImage: "books.vertical.fill") { - LibraryView(games: viewModel.libraryGames, onPlay: { gameToPlay = $0 }) + LibraryView(games: viewModel.libraryGames, onPlay: { play($0) }) } Tab("Store", systemImage: "bag.fill") { - StoreView(games: viewModel.mainGames, onPlay: { gameToPlay = $0 }) + StoreView(games: viewModel.mainGames, onPlay: { play($0) }) } Tab("Settings", systemImage: "gearshape.fill") { SettingsView() } } - .environment(viewModel) .task { await viewModel.load(authManager: authManager) } .onChange(of: viewModel.streamSettings) { viewModel.saveSettings() } .onChange(of: gameToPlay) { _, new in @@ -52,15 +51,12 @@ struct MainTabView: View { } } #if os(visionOS) - // On visionOS, open a full ImmersiveSpace instead of a modal cover. - // pendingGame/pendingSession are read by ImmersiveStreamView inside the space. .onChange(of: gameToPlay) { _, game in guard let game else { return } viewModel.pendingGame = game viewModel.pendingSession = sessionToResume Task { await openImmersiveSpace(id: "stream") } } - // When ImmersiveStreamView clears pendingGame on dismiss, sync gameToPlay back to nil. .onChange(of: viewModel.pendingGame) { _, pending in if pending == nil { gameToPlay = nil @@ -91,4 +87,9 @@ struct MainTabView: View { } #endif } + + private func play(_ game: GameInfo, session: ActiveSessionInfo? = nil) { + sessionToResume = session + gameToPlay = game + } } From ff47194a5c79bfd0bfe2f20ea2eab89e810455ba Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Fri, 24 Apr 2026 20:37:10 +0200 Subject: [PATCH 09/11] Update CLAUDE.md --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 39999b1..db50aef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,7 @@ All source lives in `CloudNow/`. Five functional areas: - `CloudMatchClient.swift` — REST client for session lifecycle: create → poll queue position → active session → stop. Also retrieves and reports queue-ad lifecycle events. - `GamesClient.swift` — GraphQL persisted queries for linked-library games and full store catalog. - `ZoneClient.swift` — Fetches regions from the PrintedWaste community API; ranks them by 40% ping + 60% queue depth score. +- `MESClient.swift` — Fetches subscription tier and VPC region ID from NVIDIA's Membership Entitlement Service (MES). Required to determine stream resolution/FPS entitlements per tier. - `SessionState.swift` — All data models: `StreamSettings`, `SessionInfo`, `GameInfo`, `QueueInfo`, etc. ### Streaming @@ -47,6 +48,8 @@ All source lives in `CloudNow/`. Five functional areas: - `SettingsView.swift` — Stream quality (resolution, FPS, codec, color, keyboard layout, game language, L4S), controller deadzone slider, zone picker, microphone toggle, account info. - `QueueAdPlayerView.swift` — AVPlayer-based queue ad playback; reports lifecycle events to CloudMatch. - `LoginView.swift` — Displays a QR code and PIN for NVIDIA device flow login; user scans the QR code or visits the URL on any device to complete OAuth. +- `ImmersiveStreamView.swift` — visionOS-only (`#if os(visionOS)`) immersive space scene wrapper for full-passthrough streaming. +- `SkeletonViews.swift` — Skeleton shimmer loading views used while game lists are fetching. ## Key Patterns From 6a81718583dbc591c5fff60d6f27645813653151 Mon Sep 17 00:00:00 2001 From: Owen Selles Date: Fri, 24 Apr 2026 20:49:47 +0200 Subject: [PATCH 10/11] Add tvOS input/UI conditionals and renderer fixes Wrap tvOS-specific input and UI behavior with #if os(tvOS) to ensure remoteMode, input pausing, focusSection, and gamepad UI only apply on tvOS. Expose RemoteInputMode enum on all platforms (removed the tvOS-only guard). Clamp saved FPS to UIScreen.main.maximumFramesPerSecond on tvOS and use a 120 Hz fallback for available FPS on non-tvOS. Remove duplicated visionOS pending session/game properties. Fix VideoSurfaceView memory access by caching plane pointers (srcY/srcU/srcV) before copying into CVPixelBuffer to avoid optional unwraps inside loops. Files changed: GFNStreamController.swift, InputSender.swift, GamesViewModel.swift, HomeView.swift, StreamView.swift, VideoSurfaceView.swift. --- CloudNow/Streaming/GFNStreamController.swift | 4 ++++ CloudNow/Streaming/InputSender.swift | 2 -- CloudNow/UI/GamesViewModel.swift | 12 ++++++------ CloudNow/UI/HomeView.swift | 6 ++++++ CloudNow/UI/StreamView.swift | 7 ++++++- CloudNow/Video/VideoSurfaceView.swift | 10 ++++++---- 6 files changed, 28 insertions(+), 13 deletions(-) diff --git a/CloudNow/Streaming/GFNStreamController.swift b/CloudNow/Streaming/GFNStreamController.swift index d317bf4..8aab368 100644 --- a/CloudNow/Streaming/GFNStreamController.swift +++ b/CloudNow/Streaming/GFNStreamController.swift @@ -770,14 +770,18 @@ extension GFNStreamController: LKRTCDataChannelDelegate { sender.setProtocolVersion(version) sender.deadzone = Float(self.settings.controllerDeadzone) sender.overlayTriggerButton = self.settings.overlayTriggerButton + #if os(tvOS) sender.remoteMode = self.settings.defaultRemoteInputMode self.remoteMode = sender.remoteMode self.videoView?.gamepadModeActive = (self.remoteMode == .gamepad || self.remoteMode == .dualsense) + #endif sender.menuToggleHandler = { [weak self] in self?.handleMenuPress() } + #if os(tvOS) sender.onRemoteModeChanged = { [weak self] mode in self?.remoteMode = mode self?.videoView?.gamepadModeActive = (mode == .gamepad || mode == .dualsense) } + #endif sender.start() self.inputSender = sender // Forward keyboard/mouse events from the video surface to the sender diff --git a/CloudNow/Streaming/InputSender.swift b/CloudNow/Streaming/InputSender.swift index f3cffed..6f51eac 100644 --- a/CloudNow/Streaming/InputSender.swift +++ b/CloudNow/Streaming/InputSender.swift @@ -42,13 +42,11 @@ private enum GFNInput { // MARK: - Remote Input Mode (tvOS / Siri Remote only) -#if os(tvOS) enum RemoteInputMode: String, Codable, Equatable { case mouse case gamepad case dualsense } -#endif // MARK: - Input Event Handler diff --git a/CloudNow/UI/GamesViewModel.swift b/CloudNow/UI/GamesViewModel.swift index cd92f17..35dae37 100644 --- a/CloudNow/UI/GamesViewModel.swift +++ b/CloudNow/UI/GamesViewModel.swift @@ -38,12 +38,6 @@ class GamesViewModel { var pendingSession: ActiveSessionInfo? = nil #endif - #if os(visionOS) - /// Set before opening the ImmersiveSpace so the content view can read the pending game. - var pendingGame: GameInfo? = nil - var pendingSession: ActiveSessionInfo? = nil - #endif - private let gamesClient = GamesClient() private let cloudMatchClient = CloudMatchClient() @@ -64,12 +58,14 @@ class GamesViewModel { let settings = try? JSONDecoder().decode(StreamSettings.self, from: data) { self.streamSettings = settings } + #if os(tvOS) // tvOS currently caps at 60 Hz; clamp any saved value to the screen maximum. // If Apple raises the cap in a future tvOS release this will automatically unlock. let screenMax = UIScreen.main.maximumFramesPerSecond if streamSettings.fps > screenMax { streamSettings.fps = screenMax } + #endif } // MARK: Computed — Entitled Resolutions & FPS @@ -92,7 +88,11 @@ class GamesViewModel { /// screen's maximum refresh rate. Today tvOS caps at 60 Hz; if Apple raises it /// in a future update this will automatically expose the higher option. var availableFps: [Int] { + #if os(tvOS) let maxFps = UIScreen.main.maximumFramesPerSecond + #else + let maxFps = 120 + #endif guard let resos = subscription?.entitledResolutions, !resos.isEmpty else { return [30, 60].filter { $0 <= maxFps } } diff --git a/CloudNow/UI/HomeView.swift b/CloudNow/UI/HomeView.swift index 4c08192..0971706 100644 --- a/CloudNow/UI/HomeView.swift +++ b/CloudNow/UI/HomeView.swift @@ -133,7 +133,9 @@ struct HomeView: View { } .padding(60) } + #if os(tvOS) .focusSection() + #endif } // MARK: Hero Banner @@ -183,7 +185,9 @@ struct HomeView: View { .padding(.horizontal, 60) .padding(.vertical, 20) } + #if os(tvOS) .focusSection() + #endif .scrollClipDisabled() } } @@ -289,6 +293,8 @@ private struct HeroBannerView: View { .frame(height: 420) .clipShape(RoundedRectangle(cornerRadius: 20)) .padding(.horizontal, 60) + #if os(tvOS) .focusSection() + #endif } } diff --git a/CloudNow/UI/StreamView.swift b/CloudNow/UI/StreamView.swift index 0380253..71fdd04 100644 --- a/CloudNow/UI/StreamView.swift +++ b/CloudNow/UI/StreamView.swift @@ -168,7 +168,11 @@ struct StreamView: View { .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. + #if os(tvOS) streamController.setInputPaused(showing && streamController.remoteMode != .mouse) + #else + streamController.setInputPaused(showing) + #endif } .alert("End Session?", isPresented: $showExitConfirmation) { Button("End Session", role: .destructive) { disconnect() } @@ -212,7 +216,6 @@ struct StreamView: View { } .buttonStyle(.borderedProminent) .tint(.white) - #endif Button(role: .destructive) { showExitConfirmation = true @@ -274,6 +277,7 @@ struct StreamView: View { .padding(60) } + #if os(tvOS) private var remoteModeLabel: String { switch streamController.remoteMode { case .mouse: return "Remote: Mouse" @@ -289,6 +293,7 @@ struct StreamView: View { case .dualsense: return "hand.point.up.left" } } + #endif private func metricRow(icon: String, label: String, value: String, history: [Double], color: Color) -> some View { HStack(spacing: 8) { diff --git a/CloudNow/Video/VideoSurfaceView.swift b/CloudNow/Video/VideoSurfaceView.swift index 66c5e25..a911c53 100644 --- a/CloudNow/Video/VideoSurfaceView.swift +++ b/CloudNow/Video/VideoSurfaceView.swift @@ -302,16 +302,18 @@ private final class WebRTCFrameRenderer: NSObject, LKRTCVideoRenderer { defer { CVPixelBufferUnlockBaseAddress(pb, []) } // Y plane - if let src = i420.dataY, let dst = CVPixelBufferGetBaseAddressOfPlane(pb, 0) { + let srcY = i420.dataY + if let dst = CVPixelBufferGetBaseAddressOfPlane(pb, 0) { let dstStride = CVPixelBufferGetBytesPerRowOfPlane(pb, 0) for row in 0.. Date: Fri, 10 Jul 2026 11:57:40 +0200 Subject: [PATCH 11/11] fix(visionos): remove duplicate Display section and apply swiftformat Removes a copy-paste duplicate #if os(visionOS) Section("Display") block in SettingsView that would have rendered two identical Immersion Style pickers on visionOS. Also applies swiftformat to all modified files. --- CloudNow/CloudNowApp.swift | 58 +-- CloudNow/Streaming/GFNStreamController.swift | 44 +- CloudNow/Streaming/InputSender.swift | 488 ++++++++++--------- CloudNow/UI/GamesViewModel.swift | 22 +- CloudNow/UI/HomeView.swift | 2 +- CloudNow/UI/ImmersiveStreamView.swift | 50 +- CloudNow/UI/LibraryView.swift | 72 +-- CloudNow/UI/MainTabView.swift | 40 +- CloudNow/UI/SettingsView.swift | 30 +- CloudNow/UI/StoreView.swift | 62 +-- CloudNow/UI/StreamView.swift | 72 +-- CloudNow/Video/VideoSurfaceView.swift | 23 +- 12 files changed, 482 insertions(+), 481 deletions(-) diff --git a/CloudNow/CloudNowApp.swift b/CloudNow/CloudNowApp.swift index 39d17f7..af1b2c2 100644 --- a/CloudNow/CloudNowApp.swift +++ b/CloudNow/CloudNowApp.swift @@ -6,7 +6,7 @@ // #if os(tvOS) -import BackgroundTasks + import BackgroundTasks #endif import SwiftUI @@ -17,10 +17,10 @@ struct CloudNowApp: App { /// sharing the same instance (pendingGame visibility). On tvOS this is the single owner too. @State private var viewModel = GamesViewModel() #if os(visionOS) - /// Drives the ImmersiveSpace immersion level. Seeded from AppStorage on launch; - /// the user can also toggle between .full and .mixed with the Digital Crown at runtime. - @AppStorage("gfn.immersionStyle") private var immersionStyleRaw: String = "full" - @State private var immersionStyle: ImmersionStyle = .full + /// Drives the ImmersiveSpace immersion level. Seeded from AppStorage on launch; + /// the user can also toggle between .full and .mixed with the Digital Crown at runtime. + @AppStorage("gfn.immersionStyle") private var immersionStyleRaw: String = "full" + @State private var immersionStyle: ImmersionStyle = .full #endif init() { @@ -42,41 +42,41 @@ struct CloudNowApp: App { .environment(authManager) .environment(viewModel) #if os(tvOS) - .onAppear { registerBGTasks() } + .onAppear { registerBGTasks() } #endif - .task { await authManager.initialize() } + .task { await authManager.initialize() } #if os(visionOS) - .task { - immersionStyle = immersionStyleRaw == "mixed" ? .mixed : .full - } - .onChange(of: immersionStyleRaw) { _, raw in - immersionStyle = raw == "mixed" ? .mixed : .full - } + .task { + immersionStyle = immersionStyleRaw == "mixed" ? .mixed : .full + } + .onChange(of: immersionStyleRaw) { _, raw in + immersionStyle = raw == "mixed" ? .mixed : .full + } #endif } #if os(visionOS) - ImmersiveSpace(id: "stream") { - ImmersiveStreamView() - .environment(authManager) - .environment(viewModel) - } - .immersionStyle(selection: $immersionStyle, in: .full, .mixed) + ImmersiveSpace(id: "stream") { + ImmersiveStreamView() + .environment(authManager) + .environment(viewModel) + } + .immersionStyle(selection: $immersionStyle, in: .full, .mixed) #endif } #if os(tvOS) - private func registerBGTasks() { - BGTaskScheduler.shared.register( - forTaskWithIdentifier: "com.owenselles.CloudNow.tokenRefresh", - using: nil - ) { task in - Task { @MainActor in - await authManager.refreshIfNeeded() - authManager.scheduleBackgroundRefresh() - task.setTaskCompleted(success: true) + private func registerBGTasks() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.owenselles.CloudNow.tokenRefresh", + using: nil + ) { task in + Task { @MainActor in + await authManager.refreshIfNeeded() + authManager.scheduleBackgroundRefresh() + task.setTaskCompleted(success: true) + } } } - } #endif } diff --git a/CloudNow/Streaming/GFNStreamController.swift b/CloudNow/Streaming/GFNStreamController.swift index 4d35fd1..212fdf6 100644 --- a/CloudNow/Streaming/GFNStreamController.swift +++ b/CloudNow/Streaming/GFNStreamController.swift @@ -164,7 +164,7 @@ final class GFNStreamController: NSObject { private var inputSender: InputSender? private(set) var videoView: VideoSurfaceView? #if os(tvOS) - private(set) var remoteMode: RemoteInputMode = .mouse + private(set) var remoteMode: RemoteInputMode = .mouse #endif private var statsTimer: Timer? private var videoReceiver: LKRTCRtpReceiver? @@ -308,9 +308,9 @@ final class GFNStreamController: NSObject { // MARK: Input Control #if os(tvOS) - func toggleRemoteMode() { - inputSender?.toggleRemoteMode() - } + func toggleRemoteMode() { + inputSender?.toggleRemoteMode() + } #endif func setInputPaused(_ paused: Bool) { @@ -362,7 +362,7 @@ final class GFNStreamController: NSObject { videoView?.menuPressHandler = nil videoView = nil #if os(tvOS) - remoteMode = .mouse + remoteMode = .mouse #endif menuPressCount = 0 timeWarning = nil @@ -486,13 +486,13 @@ final class GFNStreamController: NSObject { // On visionOS, spatial audio routing is OS-managed — only setActive is needed. do { #if os(tvOS) - try AVAudioSession.sharedInstance().setCategory( - .playback, - mode: .moviePlayback, - options: [.allowBluetooth, .allowBluetoothA2DP] - ) + try AVAudioSession.sharedInstance().setCategory( + .playback, + mode: .moviePlayback, + options: [.allowBluetooth, .allowBluetoothA2DP] + ) #else - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) #endif try AVAudioSession.sharedInstance().setActive(true) } catch { @@ -762,9 +762,9 @@ final class GFNStreamController: NSObject { private func attachMicrophone(to pc: LKRTCPeerConnection) async { #if os(tvOS) - let granted = true + let granted = true #elseif os(visionOS) - let granted = await AVAudioApplication.requestRecordPermission() + let granted = await AVAudioApplication.requestRecordPermission() #else let granted = await withCheckedContinuation { cont in AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) } @@ -1404,19 +1404,19 @@ extension GFNStreamController: LKRTCDataChannelDelegate { print("[DataChannel] Input ready — starting InputSender (protocol v\(negotiatedVersion))") let sender = InputSender(channel: self) sender.setProtocolVersion(negotiatedVersion) - sender.deadzone = Float(self.settings.controllerDeadzone) - sender.overlayTriggerButton = self.settings.overlayTriggerButton + sender.deadzone = Float(settings.controllerDeadzone) + sender.overlayTriggerButton = settings.overlayTriggerButton #if os(tvOS) - sender.remoteMode = self.settings.defaultRemoteInputMode - self.remoteMode = sender.remoteMode - self.videoView?.gamepadModeActive = (self.remoteMode == .gamepad || self.remoteMode == .dualsense) + sender.remoteMode = settings.defaultRemoteInputMode + remoteMode = sender.remoteMode + videoView?.gamepadModeActive = (remoteMode == .gamepad || remoteMode == .dualsense) #endif sender.menuToggleHandler = { [weak self] in self?.handleMenuPress() } #if os(tvOS) - sender.onRemoteModeChanged = { [weak self] mode in - self?.remoteMode = mode - self?.videoView?.gamepadModeActive = (mode == .gamepad || mode == .dualsense) - } + sender.onRemoteModeChanged = { [weak self] mode in + self?.remoteMode = mode + self?.videoView?.gamepadModeActive = (mode == .gamepad || mode == .dualsense) + } #endif sender.start() inputSender = sender diff --git a/CloudNow/Streaming/InputSender.swift b/CloudNow/Streaming/InputSender.swift index 6f51eac..4d1858c 100644 --- a/CloudNow/Streaming/InputSender.swift +++ b/CloudNow/Streaming/InputSender.swift @@ -4,40 +4,40 @@ import GameController // MARK: - GFN Input Protocol Constants private enum GFNInput { - static let keyDown: UInt8 = 3 - static let keyUp: UInt8 = 4 - static let mouseRel: UInt8 = 7 + static let keyDown: UInt8 = 3 + static let keyUp: UInt8 = 4 + static let mouseRel: UInt8 = 7 static let mouseBtnDown: UInt8 = 8 - static let mouseBtnUp: UInt8 = 9 - static let mouseWheel: UInt8 = 10 - static let gamepad: UInt8 = 12 - // Heartbeat type (u32 LE value 2) — keeps the server's virtual gamepad alive + static let mouseBtnUp: UInt8 = 9 + static let mouseWheel: UInt8 = 10 + static let gamepad: UInt8 = 12 + /// Heartbeat type (u32 LE value 2) — keeps the server's virtual gamepad alive static let heartbeatU32: UInt32 = 2 - // Gamepad packet: 38 bytes, u32 LE type per GFN protocol + /// Gamepad packet: 38 bytes, u32 LE type per GFN protocol static let gamepadPacketSize = 38 // Keyboard/mouse packets use 4-byte UInt32 LE type (matches TS InputEncoder) - static let keyboardPacketSize = 18 + static let keyboardPacketSize = 18 static let mouseButtonPacketSize = 18 - static let mouseMovePacketSize = 22 - static let mouseWheelPacketSize = 22 + static let mouseMovePacketSize = 22 + static let mouseWheelPacketSize = 22 // XInput button flags - static let dpadUp: UInt16 = 0x0001 - static let dpadDown: UInt16 = 0x0002 - static let dpadLeft: UInt16 = 0x0004 + static let dpadUp: UInt16 = 0x0001 + static let dpadDown: UInt16 = 0x0002 + static let dpadLeft: UInt16 = 0x0004 static let dpadRight: UInt16 = 0x0008 - static let start: UInt16 = 0x0010 - static let back: UInt16 = 0x0020 - static let ls: UInt16 = 0x0040 - static let rs: UInt16 = 0x0080 - static let lb: UInt16 = 0x0100 - static let rb: UInt16 = 0x0200 - static let guide: UInt16 = 0x0400 - static let buttonA: UInt16 = 0x1000 - static let buttonB: UInt16 = 0x2000 - static let buttonX: UInt16 = 0x4000 - static let buttonY: UInt16 = 0x8000 + static let start: UInt16 = 0x0010 + static let back: UInt16 = 0x0020 + static let ls: UInt16 = 0x0040 + static let rs: UInt16 = 0x0080 + static let lb: UInt16 = 0x0100 + static let rb: UInt16 = 0x0200 + static let guide: UInt16 = 0x0400 + static let buttonA: UInt16 = 0x1000 + static let buttonB: UInt16 = 0x2000 + static let buttonX: UInt16 = 0x4000 + static let buttonY: UInt16 = 0x8000 } // MARK: - Remote Input Mode (tvOS / Siri Remote only) @@ -66,7 +66,9 @@ final class InputEncoder { private var protocolVersion = 2 private var gamepadSequence = [Int: UInt16]() - func setProtocolVersion(_ v: Int) { protocolVersion = v } + func setProtocolVersion(_ v: Int) { + protocolVersion = v + } // MARK: Heartbeat @@ -94,12 +96,12 @@ final class InputEncoder { gamepadBitmap: UInt8 ) -> Data { var buf = Data(count: GFNInput.gamepadPacketSize) - writeUInt32LE(&buf, offset: 0, value: 12) // type - writeUInt16LE(&buf, offset: 4, value: 26) // payload size - writeUInt16LE(&buf, offset: 6, value: UInt16(controllerId & 3)) // gamepad index - writeUInt16LE(&buf, offset: 8, value: UInt16(gamepadBitmap)) // connected-controller bitmask - writeUInt16LE(&buf, offset: 10, value: 20) // inner payload size - writeUInt16LE(&buf, offset: 12, value: buttons) // XInput buttons + writeUInt32LE(&buf, offset: 0, value: 12) // type + writeUInt16LE(&buf, offset: 4, value: 26) // payload size + writeUInt16LE(&buf, offset: 6, value: UInt16(controllerId & 3)) // gamepad index + writeUInt16LE(&buf, offset: 8, value: UInt16(gamepadBitmap)) // connected-controller bitmask + writeUInt16LE(&buf, offset: 10, value: 20) // inner payload size + writeUInt16LE(&buf, offset: 12, value: buttons) // XInput buttons buf[14] = leftTrigger buf[15] = rightTrigger writeInt16LE(&buf, offset: 16, value: leftStickX) @@ -107,15 +109,16 @@ final class InputEncoder { writeInt16LE(&buf, offset: 20, value: rightStickX) writeInt16LE(&buf, offset: 22, value: rightStickY) // buf[24–25]: reserved (zero) - buf[26] = 0x55 // magic constant required by GFN protocol + buf[26] = 0x55 // magic constant required by GFN protocol // buf[27–29]: reserved (zero) - writeTimestampLE(&buf, offset: 30) // u64 LE microseconds + writeTimestampLE(&buf, offset: 30) // u64 LE microseconds return protocolVersion >= 3 ? wrapGamepadPartiallyReliable(buf, gamepadIndex: controllerId) : buf } // MARK: Keyboard + // Packet (18 bytes): [UInt32 LE type][UInt16 BE vk][UInt16 BE mods][UInt16 BE scan][UInt64 BE ts] func encodeKeyboard(down: Bool, vk: UInt16, scancode: UInt16, modifiers: UInt16) -> Data { @@ -129,6 +132,7 @@ final class InputEncoder { } // MARK: Mouse Move + // Packet (22 bytes): [UInt32 LE type][Int16 BE dx][Int16 BE dy][6B reserved][UInt64 BE ts] func encodeMouseMove(dx: Int16, dy: Int16) -> Data { @@ -142,6 +146,7 @@ final class InputEncoder { } // MARK: Mouse Button + // Packet (18 bytes): [UInt32 LE type][UInt8 button][1B pad][4B reserved][UInt64 BE ts] func encodeMouseButton(down: Bool, button: UInt8) -> Data { @@ -154,6 +159,7 @@ final class InputEncoder { } // MARK: Mouse Wheel + // Packet (22 bytes): [UInt32 LE type][2B reserved][Int16 BE vert][6B reserved][UInt64 BE ts] func encodeMouseWheel(delta: Int16) -> Data { @@ -199,7 +205,7 @@ final class InputEncoder { var buf = Data(count: 9 + 1 + 1 + 2 + 1 + 2 + payload.count) buf[0] = 0x23 writeTimestampBE(&buf, offset: 1) - buf[9] = 0x26 + buf[9] = 0x26 buf[10] = UInt8(gamepadIndex & 0xFF) buf[11] = UInt8(seq >> 8) buf[12] = UInt8(seq & 0xFF) @@ -212,21 +218,21 @@ final class InputEncoder { private func nextGamepadSequence(_ idx: Int) -> UInt16 { let current = gamepadSequence[idx] ?? 1 - gamepadSequence[idx] = current &+ 1 // wraps at 65535 + gamepadSequence[idx] = current &+ 1 // wraps at 65535 return current } // MARK: Write Helpers private func writeUInt16LE(_ buf: inout Data, offset: Int, value: UInt16) { - buf[offset] = UInt8(value & 0xFF) + buf[offset] = UInt8(value & 0xFF) buf[offset + 1] = UInt8(value >> 8) } private func writeTimestampLE(_ buf: inout Data, offset: Int) { let tsUs = UInt64(Date().timeIntervalSince1970 * 1_000_000) - buf[offset] = UInt8(tsUs & 0xFF) - buf[offset + 1] = UInt8((tsUs >> 8) & 0xFF) + buf[offset] = UInt8(tsUs & 0xFF) + buf[offset + 1] = UInt8((tsUs >> 8) & 0xFF) buf[offset + 2] = UInt8((tsUs >> 16) & 0xFF) buf[offset + 3] = UInt8((tsUs >> 24) & 0xFF) buf[offset + 4] = UInt8((tsUs >> 32) & 0xFF) @@ -236,39 +242,39 @@ final class InputEncoder { } private func writeUInt32LE(_ buf: inout Data, offset: Int, value: UInt32) { - buf[offset] = UInt8(value & 0xFF) + buf[offset] = UInt8(value & 0xFF) buf[offset + 1] = UInt8((value >> 8) & 0xFF) buf[offset + 2] = UInt8((value >> 16) & 0xFF) buf[offset + 3] = UInt8((value >> 24) & 0xFF) } private func writeUInt16BE(_ buf: inout Data, offset: Int, value: UInt16) { - buf[offset] = UInt8(value >> 8) + buf[offset] = UInt8(value >> 8) buf[offset + 1] = UInt8(value & 0xFF) } private func writeInt16BE(_ buf: inout Data, offset: Int, value: Int16) { let v = UInt16(bitPattern: value) - buf[offset] = UInt8(v >> 8) + buf[offset] = UInt8(v >> 8) buf[offset + 1] = UInt8(v & 0xFF) } private func writeInt16LE(_ buf: inout Data, offset: Int, value: Int16) { let v = UInt16(bitPattern: value) - buf[offset] = UInt8(v & 0xFF) + buf[offset] = UInt8(v & 0xFF) buf[offset + 1] = UInt8(v >> 8) } private func writeTimestampBE(_ buf: inout Data, offset: Int) { let tsUs = UInt64(Date().timeIntervalSince1970 * 1_000_000) - buf[offset] = UInt8((tsUs >> 56) & 0xFF) + buf[offset] = UInt8((tsUs >> 56) & 0xFF) buf[offset + 1] = UInt8((tsUs >> 48) & 0xFF) buf[offset + 2] = UInt8((tsUs >> 40) & 0xFF) buf[offset + 3] = UInt8((tsUs >> 32) & 0xFF) buf[offset + 4] = UInt8((tsUs >> 24) & 0xFF) buf[offset + 5] = UInt8((tsUs >> 16) & 0xFF) - buf[offset + 6] = UInt8((tsUs >> 8) & 0xFF) - buf[offset + 7] = UInt8((tsUs ) & 0xFF) + buf[offset + 6] = UInt8((tsUs >> 8) & 0xFF) + buf[offset + 7] = UInt8(tsUs & 0xFF) } } @@ -283,17 +289,19 @@ func mapGCControllerToXInput(_ controller: GCController, deadzone: Float = 0.15) } var buttons: UInt16 = 0 - func pressed(_ e: GCControllerButtonInput) -> Bool { e.isPressed } + func pressed(_ e: GCControllerButtonInput) -> Bool { + e.isPressed + } - if pressed(pad.dpad.up) { buttons |= GFNInput.dpadUp } - if pressed(pad.dpad.down) { buttons |= GFNInput.dpadDown } - if pressed(pad.dpad.left) { buttons |= GFNInput.dpadLeft } + if pressed(pad.dpad.up) { buttons |= GFNInput.dpadUp } + if pressed(pad.dpad.down) { buttons |= GFNInput.dpadDown } + if pressed(pad.dpad.left) { buttons |= GFNInput.dpadLeft } if pressed(pad.dpad.right) { buttons |= GFNInput.dpadRight } if pressed(pad.buttonMenu) { buttons |= GFNInput.start } if pressed(pad.buttonOptions ?? pad.buttonMenu) { buttons |= GFNInput.back } - if let ls = pad.leftThumbstickButton, pressed(ls) { buttons |= GFNInput.ls } + if let ls = pad.leftThumbstickButton, pressed(ls) { buttons |= GFNInput.ls } if let rs = pad.rightThumbstickButton, pressed(rs) { buttons |= GFNInput.rs } - if pressed(pad.leftShoulder) { buttons |= GFNInput.lb } + if pressed(pad.leftShoulder) { buttons |= GFNInput.lb } if pressed(pad.rightShoulder) { buttons |= GFNInput.rb } if pressed(pad.buttonA) { buttons |= GFNInput.buttonA } if pressed(pad.buttonB) { buttons |= GFNInput.buttonB } @@ -330,11 +338,11 @@ protocol DataChannelSender: AnyObject { /// over a WebRTC data channel at 60 Hz. final class InputSender { #if os(tvOS) - /// Pixel delta applied per unit of Siri Remote axis deflection per 60 Hz frame. - static let remoteSensitivity: Float = 250.0 + /// Pixel delta applied per unit of Siri Remote axis deflection per 60 Hz frame. + static let remoteSensitivity: Float = 250.0 - /// Siri Remote input mode. Defaults to .mouse so the touchpad drives the cursor. - private(set) var remoteMode: RemoteInputMode = .mouse + /// Siri Remote input mode. Defaults to .mouse so the touchpad drives the cursor. + private(set) var remoteMode: RemoteInputMode = .mouse #endif /// Radial deadzone for analog stick axes (0.0–1.0). Set from StreamSettings.controllerDeadzone. @@ -350,8 +358,8 @@ final class InputSender { var overlayTriggerButton: OverlayTriggerButton = .start #if os(tvOS) - /// Called when remoteMode changes due to controller connect/disconnect auto-switching. - var onRemoteModeChanged: ((RemoteInputMode) -> Void)? + /// Called when remoteMode changes due to controller connect/disconnect auto-switching. + var onRemoteModeChanged: ((RemoteInputMode) -> Void)? #endif private weak var channel: DataChannelSender? @@ -360,22 +368,22 @@ final class InputSender { private var heartbeatTimer: Timer? private var observations: [NSObjectProtocol] = [] - // Gamepad bitmap: bit i = extended gamepad i is connected (matches official GFN protocol) + /// Gamepad bitmap: bit i = extended gamepad i is connected (matches official GFN protocol) private var gamepadBitmap: UInt8 = 0 #if os(tvOS) - // Siri Remote state tracking - private var lastMicroDpad: (x: Float, y: Float) = (0, 0) - private var lastMicroButtonA = false + // Siri Remote state tracking + private var lastMicroDpad: (x: Float, y: Float) = (0, 0) + private var lastMicroButtonA = false #endif // DualSense touchpad state tracking private var lastDualSenseTouchpad: (x: Float, y: Float) = (0, 0) private var lastDualSenseTouchpadClick = false - // Per-controller overlay trigger hold duration (ticks at 60 Hz) + /// Per-controller overlay trigger hold duration (ticks at 60 Hz) private var overlayHoldTicks: [Int: Int] = [:] - // 1800 ms at 60 Hz, matches official GFN IGO gamepadLongStartPressDefaultDuration + /// 1800 ms at 60 Hz, matches official GFN IGO gamepadLongStartPressDefaultDuration private static let overlayLongPressThreshold = 108 init(channel: DataChannelSender) { @@ -394,7 +402,7 @@ final class InputSender { // Sent unconditionally (not gated on isPaused) to maintain the connection. heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in guard let self else { return } - self.channel?.sendData(self.encoder.encodeHeartbeat()) + channel?.sendData(encoder.encodeHeartbeat()) } } @@ -411,31 +419,32 @@ final class InputSender { } #if os(tvOS) - // MARK: Remote Mode - func toggleRemoteMode() { - switch remoteMode { - case .mouse: remoteMode = .gamepad - case .gamepad: remoteMode = .dualsense - case .dualsense: remoteMode = .mouse + // MARK: Remote Mode + + func toggleRemoteMode() { + switch remoteMode { + case .mouse: remoteMode = .gamepad + case .gamepad: remoteMode = .dualsense + case .dualsense: remoteMode = .mouse + } + applyRemoteMode() } - applyRemoteMode() - } - private func applyRemoteMode() { - lastMicroDpad = (0, 0) - lastMicroButtonA = false - lastDualSenseTouchpad = (0, 0) - lastDualSenseTouchpadClick = false - overlayHoldTicks.removeAll() - for controller in GCController.controllers() where controller.extendedGamepad != nil { - if remoteMode == .gamepad || remoteMode == .dualsense { - claimControllerInput(controller) - } else { - releaseControllerInput(controller) + private func applyRemoteMode() { + lastMicroDpad = (0, 0) + lastMicroButtonA = false + lastDualSenseTouchpad = (0, 0) + lastDualSenseTouchpadClick = false + overlayHoldTicks.removeAll() + for controller in GCController.controllers() where controller.extendedGamepad != nil { + if remoteMode == .gamepad || remoteMode == .dualsense { + claimControllerInput(controller) + } else { + releaseControllerInput(controller) + } } } - } #endif // MARK: Private — Tick @@ -445,43 +454,78 @@ final class InputSender { let extended = controllers.filter { $0.extendedGamepad != nil } #if os(tvOS) - let micro = controllers.filter { $0.extendedGamepad == nil && $0.microGamepad != nil } - if extended.isEmpty && micro.isEmpty { return } + let micro = controllers.filter { $0.extendedGamepad == nil && $0.microGamepad != nil } + if extended.isEmpty, micro.isEmpty { return } #else - if extended.isEmpty { return } + if extended.isEmpty { return } #endif #if os(tvOS) - if remoteMode == .gamepad || remoteMode == .dualsense { - // Gamepad/DualSense mode: extended controller owns the game; remote is suppressed when - // a real controller is present (otherwise the remote's empty state overwrites it). - for (idx, controller) in extended.prefix(4).enumerated() { - var (btns, lt, rt, lx, ly, rx, ry) = mapGCControllerToXInput(controller, deadzone: deadzone) - - // Long-press overlay trigger → show GFN overlay. - // Runs before sendData so we can clear the triggering bit, - // preventing the in-game action from firing simultaneously. - if let pad = controller.extendedGamepad { - let held: Bool - switch overlayTriggerButton { - case .start: held = pad.buttonMenu.isPressed - case .options: held = pad.buttonOptions?.isPressed ?? false - } - if held { - let ticks = (overlayHoldTicks[idx] ?? 0) + 1 - overlayHoldTicks[idx] = ticks - if ticks == Self.overlayLongPressThreshold { - switch overlayTriggerButton { - case .start: btns &= ~GFNInput.start - case .options: btns &= ~GFNInput.back + if remoteMode == .gamepad || remoteMode == .dualsense { + // Gamepad/DualSense mode: extended controller owns the game; remote is suppressed when + // a real controller is present (otherwise the remote's empty state overwrites it). + for (idx, controller) in extended.prefix(4).enumerated() { + var (btns, lt, rt, lx, ly, rx, ry) = mapGCControllerToXInput(controller, deadzone: deadzone) + + // Long-press overlay trigger → show GFN overlay. + // Runs before sendData so we can clear the triggering bit, + // preventing the in-game action from firing simultaneously. + if let pad = controller.extendedGamepad { + let held: Bool = switch overlayTriggerButton { + case .start: pad.buttonMenu.isPressed + case .options: pad.buttonOptions?.isPressed ?? false + } + if held { + let ticks = (overlayHoldTicks[idx] ?? 0) + 1 + overlayHoldTicks[idx] = ticks + if ticks == Self.overlayLongPressThreshold { + switch overlayTriggerButton { + case .start: btns &= ~GFNInput.start + case .options: btns &= ~GFNInput.back + } + menuToggleHandler?() } - menuToggleHandler?() + } else { + overlayHoldTicks[idx] = 0 } - } else { - overlayHoldTicks[idx] = 0 } + + let data = encoder.encodeGamepad( + controllerId: idx, + buttons: btns, + leftTrigger: lt, + rightTrigger: rt, + leftStickX: lx, + leftStickY: ly, + rightStickX: rx, + rightStickY: ry, + gamepadBitmap: gamepadBitmap + ) + channel?.sendData(data) } + // DualSense mode: poll touchpad for mouse movement alongside regular gamepad packets + if remoteMode == .dualsense, !isPaused { + if let ds = extended.first(where: { $0.extendedGamepad is GCDualSenseGamepad }) { + handleDualSenseTouchpad(ds) + } + } + + // Only use the Siri Remote as a gamepad when no real controller is connected + if extended.isEmpty, !isPaused, let remote = micro.first { + handleMicroGamepad(remote) + } + } else { + // Mouse mode: extended controller is handed back to tvOS for system navigation. + // Only the Siri Remote sends input to the game. + overlayHoldTicks.removeAll() + if !isPaused, let remote = micro.first { + handleMicroGamepad(remote) + } + } + #else + for (idx, controller) in extended.prefix(4).enumerated() { + let (btns, lt, rt, lx, ly, rx, ry) = mapGCControllerToXInput(controller, deadzone: deadzone) let data = encoder.encodeGamepad( controllerId: idx, buttons: btns, @@ -495,128 +539,92 @@ final class InputSender { ) channel?.sendData(data) } + #endif + } + + #if os(tvOS) + private func handleMicroGamepad(_ controller: GCController) { + guard let pad = controller.microGamepad else { return } + + let curX = pad.dpad.xAxis.value + let curY = pad.dpad.yAxis.value + // Treat the touchpad as "not being touched" when position is near centre. + // This prevents a snap-back mouseRel when the finger lifts and dpad returns to (0,0). + let isTouching = abs(curX) > 0.02 || abs(curY) > 0.02 + let wasTouching = abs(lastMicroDpad.x) > 0.02 || abs(lastMicroDpad.y) > 0.02 + // Compute delta before updating the reference so we don't compare a value with itself. + let dx = curX - lastMicroDpad.x + let dy = curY - lastMicroDpad.y + lastMicroDpad = (curX, curY) + + switch remoteMode { + case .mouse: + // Only send delta while the finger is continuously on the pad. + // Ignore the first frame of a new touch (wasTouching=false) and the + // release frame (isTouching=false) to avoid jump artefacts. + if isTouching, wasTouching, abs(dx) > 0.0005 || abs(dy) > 0.0005 { + let pxDx = Int16(clamping: Int((dx * Self.remoteSensitivity).rounded())) + let pxDy = Int16(clamping: Int((-dy * Self.remoteSensitivity).rounded())) + sendMouseMove(dx: pxDx, dy: pxDy) + } - // DualSense mode: poll touchpad for mouse movement alongside regular gamepad packets - if remoteMode == .dualsense, !isPaused { - if let ds = extended.first(where: { $0.extendedGamepad is GCDualSenseGamepad }) { - handleDualSenseTouchpad(ds) + // Select / click → left mouse button + let aPressed = pad.buttonA.isPressed + if aPressed != lastMicroButtonA { + lastMicroButtonA = aPressed + sendMouseButton(down: aPressed, button: 1) } - } - // Only use the Siri Remote as a gamepad when no real controller is connected - if extended.isEmpty, !isPaused, let remote = micro.first { - handleMicroGamepad(remote) - } - } else { - // Mouse mode: extended controller is handed back to tvOS for system navigation. - // Only the Siri Remote sends input to the game. - overlayHoldTicks.removeAll() - if !isPaused, let remote = micro.first { - handleMicroGamepad(remote) + // Play/Pause is handled by VideoSurfaceView (UIKit pressesBegan) as an overlay toggle. + // Do not forward it to the game from here to avoid double-firing. + _ = pad.buttonX.isPressed // read to prevent GameController from coalescing + + case .gamepad: + var buttons: UInt16 = 0 + if pad.dpad.up.isPressed { buttons |= GFNInput.dpadUp } + if pad.dpad.down.isPressed { buttons |= GFNInput.dpadDown } + if pad.dpad.left.isPressed { buttons |= GFNInput.dpadLeft } + if pad.dpad.right.isPressed { buttons |= GFNInput.dpadRight } + if pad.buttonA.isPressed { buttons |= GFNInput.buttonA } + // buttonX (Play/Pause) is reserved for the overlay toggle — not forwarded to game + + let data = encoder.encodeGamepad( + controllerId: 0, buttons: buttons, + leftTrigger: 0, rightTrigger: 0, + leftStickX: 0, leftStickY: 0, + rightStickX: 0, rightStickY: 0, + gamepadBitmap: gamepadBitmap | 1 // Siri Remote acts as slot 0 + ) + channel?.sendData(data) + + case .dualsense: + break // Siri Remote is suppressed in DualSense mode; touchpad handled separately } } - #else - for (idx, controller) in extended.prefix(4).enumerated() { - let (btns, lt, rt, lx, ly, rx, ry) = mapGCControllerToXInput(controller, deadzone: deadzone) - let data = encoder.encodeGamepad( - controllerId: idx, - buttons: btns, - leftTrigger: lt, - rightTrigger: rt, - leftStickX: lx, - leftStickY: ly, - rightStickX: rx, - rightStickY: ry, - gamepadBitmap: gamepadBitmap - ) - channel?.sendData(data) - } - #endif - } - #if os(tvOS) - private func handleMicroGamepad(_ controller: GCController) { - guard let pad = controller.microGamepad else { return } - - let curX = pad.dpad.xAxis.value - let curY = pad.dpad.yAxis.value - // Treat the touchpad as "not being touched" when position is near centre. - // This prevents a snap-back mouseRel when the finger lifts and dpad returns to (0,0). - let isTouching = abs(curX) > 0.02 || abs(curY) > 0.02 - let wasTouching = abs(lastMicroDpad.x) > 0.02 || abs(lastMicroDpad.y) > 0.02 - // Compute delta before updating the reference so we don't compare a value with itself. - let dx = curX - lastMicroDpad.x - let dy = curY - lastMicroDpad.y - lastMicroDpad = (curX, curY) - - switch remoteMode { - case .mouse: - // Only send delta while the finger is continuously on the pad. - // Ignore the first frame of a new touch (wasTouching=false) and the - // release frame (isTouching=false) to avoid jump artefacts. - if isTouching && wasTouching && (abs(dx) > 0.0005 || abs(dy) > 0.0005) { + private func handleDualSenseTouchpad(_ controller: GCController) { + guard let dualSense = controller.extendedGamepad as? GCDualSenseGamepad else { return } + let curX = dualSense.touchpadPrimary.xAxis.value + let curY = dualSense.touchpadPrimary.yAxis.value + + let isTouching = abs(curX) > 0.02 || abs(curY) > 0.02 + let wasTouching = abs(lastDualSenseTouchpad.x) > 0.02 || abs(lastDualSenseTouchpad.y) > 0.02 + let dx = curX - lastDualSenseTouchpad.x + let dy = curY - lastDualSenseTouchpad.y + lastDualSenseTouchpad = (curX, curY) + + if isTouching, wasTouching, abs(dx) > 0.0005 || abs(dy) > 0.0005 { let pxDx = Int16(clamping: Int((dx * Self.remoteSensitivity).rounded())) let pxDy = Int16(clamping: Int((-dy * Self.remoteSensitivity).rounded())) sendMouseMove(dx: pxDx, dy: pxDy) } - // Select / click → left mouse button - let aPressed = pad.buttonA.isPressed - if aPressed != lastMicroButtonA { - lastMicroButtonA = aPressed - sendMouseButton(down: aPressed, button: 1) + let clicked = dualSense.touchpadButton.isPressed + if clicked != lastDualSenseTouchpadClick { + lastDualSenseTouchpadClick = clicked + sendMouseButton(down: clicked, button: 1) } - - // Play/Pause is handled by VideoSurfaceView (UIKit pressesBegan) as an overlay toggle. - // Do not forward it to the game from here to avoid double-firing. - _ = pad.buttonX.isPressed // read to prevent GameController from coalescing - - case .gamepad: - var buttons: UInt16 = 0 - if pad.dpad.up.isPressed { buttons |= GFNInput.dpadUp } - if pad.dpad.down.isPressed { buttons |= GFNInput.dpadDown } - if pad.dpad.left.isPressed { buttons |= GFNInput.dpadLeft } - if pad.dpad.right.isPressed { buttons |= GFNInput.dpadRight } - if pad.buttonA.isPressed { buttons |= GFNInput.buttonA } - // buttonX (Play/Pause) is reserved for the overlay toggle — not forwarded to game - - let data = encoder.encodeGamepad( - controllerId: 0, buttons: buttons, - leftTrigger: 0, rightTrigger: 0, - leftStickX: 0, leftStickY: 0, - rightStickX: 0, rightStickY: 0, - gamepadBitmap: gamepadBitmap | 1 // Siri Remote acts as slot 0 - ) - channel?.sendData(data) - - case .dualsense: - break // Siri Remote is suppressed in DualSense mode; touchpad handled separately } - } - - private func handleDualSenseTouchpad(_ controller: GCController) { - guard let dualSense = controller.extendedGamepad as? GCDualSenseGamepad else { return } - let curX = dualSense.touchpadPrimary.xAxis.value - let curY = dualSense.touchpadPrimary.yAxis.value - - let isTouching = abs(curX) > 0.02 || abs(curY) > 0.02 - let wasTouching = abs(lastDualSenseTouchpad.x) > 0.02 || abs(lastDualSenseTouchpad.y) > 0.02 - let dx = curX - lastDualSenseTouchpad.x - let dy = curY - lastDualSenseTouchpad.y - lastDualSenseTouchpad = (curX, curY) - - if isTouching && wasTouching && (abs(dx) > 0.0005 || abs(dy) > 0.0005) { - let pxDx = Int16(clamping: Int((dx * Self.remoteSensitivity).rounded())) - let pxDy = Int16(clamping: Int((-dy * Self.remoteSensitivity).rounded())) - sendMouseMove(dx: pxDx, dy: pxDy) - } - - let clicked = dualSense.touchpadButton.isPressed - if clicked != lastDualSenseTouchpadClick { - lastDualSenseTouchpadClick = clicked - sendMouseButton(down: clicked, button: 1) - } - } #endif // MARK: Private — Controller Notifications @@ -659,8 +667,8 @@ final class InputSender { let idx = GCController.controllers().firstIndex(where: { $0 === controller }) ?? 0 gamepadBitmap |= (1 << UInt8(idx & 3)) #if os(tvOS) - // System gesture ownership is only claimed when in gamepad mode (starts as .mouse). - if remoteMode == .gamepad { claimControllerInput(controller) } + // System gesture ownership is only claimed when in gamepad mode (starts as .mouse). + if remoteMode == .gamepad { claimControllerInput(controller) } #endif } @@ -680,7 +688,7 @@ final class InputSender { let dx = Int16(clamping: Int(deltaX.rounded())) let dy = Int16(clamping: Int((-deltaY).rounded())) if dx != 0 || dy != 0 { - self.sendMouseMove(dx: dx, dy: dy) + sendMouseMove(dx: dx, dy: dy) } } @@ -699,7 +707,7 @@ final class InputSender { input.scroll.valueChangedHandler = { [weak self] _, _, yValue in guard let self, !self.isPaused else { return } let delta = Int16(clamping: Int((-yValue * 3).rounded())) - if delta != 0 { self.sendMouseWheel(delta: delta) } + if delta != 0 { sendMouseWheel(delta: delta) } } } @@ -747,14 +755,14 @@ final class InputSender { let idx = GCController.controllers().firstIndex(where: { $0 === controller }) ?? 0 gamepadBitmap |= (1 << UInt8(idx & 3)) #if os(tvOS) - // Auto-switch to gamepad mode when a real controller connects. - if remoteMode == .mouse { - remoteMode = .gamepad - applyRemoteMode() - onRemoteModeChanged?(remoteMode) - } else { - claimControllerInput(controller) - } + // Auto-switch to gamepad mode when a real controller connects. + if remoteMode == .mouse { + remoteMode = .gamepad + applyRemoteMode() + onRemoteModeChanged?(remoteMode) + } else { + claimControllerInput(controller) + } #endif let data = encoder.encodeGamepad( controllerId: idx, buttons: 0, leftTrigger: 0, rightTrigger: 0, @@ -769,12 +777,12 @@ final class InputSender { let idx = GCController.controllers().firstIndex(where: { $0 === controller }) ?? 0 gamepadBitmap &= ~(1 << UInt8(idx & 3)) #if os(tvOS) - // Revert to mouse mode when the last controller disconnects. - if gamepadBitmap == 0 && remoteMode != .mouse { - remoteMode = .mouse - applyRemoteMode() - onRemoteModeChanged?(remoteMode) - } + // Revert to mouse mode when the last controller disconnects. + if gamepadBitmap == 0, remoteMode != .mouse { + remoteMode = .mouse + applyRemoteMode() + onRemoteModeChanged?(remoteMode) + } #endif let data = encoder.encodeGamepad( controllerId: idx, buttons: 0, leftTrigger: 0, rightTrigger: 0, diff --git a/CloudNow/UI/GamesViewModel.swift b/CloudNow/UI/GamesViewModel.swift index 712ee04..03cc5f9 100644 --- a/CloudNow/UI/GamesViewModel.swift +++ b/CloudNow/UI/GamesViewModel.swift @@ -51,9 +51,9 @@ class GamesViewModel { var topZones: [GFNZone] = [] #if os(visionOS) - /// Set before opening the ImmersiveSpace so the content view can read the pending game. - var pendingGame: GameInfo? = nil - var pendingSession: ActiveSessionInfo? = nil + /// Set before opening the ImmersiveSpace so the content view can read the pending game. + var pendingGame: GameInfo? + var pendingSession: ActiveSessionInfo? #endif private let gamesClient = GamesClient() @@ -86,12 +86,12 @@ class GamesViewModel { lastSession = session } #if os(tvOS) - // tvOS currently caps at 60 Hz; clamp any saved value to the screen maximum. - // If Apple raises the cap in a future tvOS release this will automatically unlock. - let screenMax = (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.screen.maximumFramesPerSecond) ?? 60 - if streamSettings.fps > screenMax { - streamSettings.fps = screenMax - } + // tvOS currently caps at 60 Hz; clamp any saved value to the screen maximum. + // If Apple raises the cap in a future tvOS release this will automatically unlock. + let screenMax = (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.screen.maximumFramesPerSecond) ?? 60 + if streamSettings.fps > screenMax { + streamSettings.fps = screenMax + } #endif streamSettings = streamSettings.normalizedForClient } @@ -117,9 +117,9 @@ class GamesViewModel { /// in a future update this will automatically expose the higher option. var availableFps: [Int] { #if os(tvOS) - let maxFps = (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.screen.maximumFramesPerSecond) ?? 60 + let maxFps = (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.screen.maximumFramesPerSecond) ?? 60 #else - let maxFps = 120 + let maxFps = 120 #endif guard let resos = subscription?.entitledResolutions, !resos.isEmpty else { return [30, 60].filter { $0 <= maxFps } diff --git a/CloudNow/UI/HomeView.swift b/CloudNow/UI/HomeView.swift index 4a0e6c2..9e098e5 100644 --- a/CloudNow/UI/HomeView.swift +++ b/CloudNow/UI/HomeView.swift @@ -294,7 +294,7 @@ private struct HeroBannerView: View { .clipShape(RoundedRectangle(cornerRadius: 20)) .padding(.horizontal, 60) #if os(tvOS) - .focusSection() + .focusSection() #endif } } diff --git a/CloudNow/UI/ImmersiveStreamView.swift b/CloudNow/UI/ImmersiveStreamView.swift index 85fb46d..6188327 100644 --- a/CloudNow/UI/ImmersiveStreamView.swift +++ b/CloudNow/UI/ImmersiveStreamView.swift @@ -1,32 +1,32 @@ #if os(visionOS) -import SwiftUI + import SwiftUI -/// Root content view for the streaming ImmersiveSpace scene. -/// Reads the pending game set by MainTabView before the space was opened, -/// hosts StreamView full-screen, and dismisses the space when the stream ends. -struct ImmersiveStreamView: View { - @Environment(GamesViewModel.self) var viewModel - @Environment(AuthManager.self) var authManager - @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace + /// Root content view for the streaming ImmersiveSpace scene. + /// Reads the pending game set by MainTabView before the space was opened, + /// hosts StreamView full-screen, and dismisses the space when the stream ends. + struct ImmersiveStreamView: View { + @Environment(GamesViewModel.self) var viewModel + @Environment(AuthManager.self) var authManager + @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace - var body: some View { - if let game = viewModel.pendingGame { - ZStack { - Color.black.ignoresSafeArea() - StreamView( - game: game, - settings: viewModel.streamSettings, - existingSession: viewModel.pendingSession, - onDismiss: { - viewModel.pendingGame = nil - viewModel.pendingSession = nil - Task { await dismissImmersiveSpace() } - } - ) - .environment(authManager) - .environment(viewModel) + var body: some View { + if let game = viewModel.pendingGame { + ZStack { + Color.black.ignoresSafeArea() + StreamView( + game: game, + settings: viewModel.streamSettings, + existingSession: viewModel.pendingSession, + onDismiss: { + viewModel.pendingGame = nil + viewModel.pendingSession = nil + Task { await dismissImmersiveSpace() } + } + ) + .environment(authManager) + .environment(viewModel) + } } } } -} #endif diff --git a/CloudNow/UI/LibraryView.swift b/CloudNow/UI/LibraryView.swift index 3f458de..91fcbc6 100644 --- a/CloudNow/UI/LibraryView.swift +++ b/CloudNow/UI/LibraryView.swift @@ -1,9 +1,9 @@ import SwiftUI private enum LibrarySortOrder: String, CaseIterable { - case `default` = "Default" - case titleAZ = "A → Z" - case titleZA = "Z → A" + case `default` = "Default" + case titleAZ = "A → Z" + case titleZA = "Z → A" case recentFirst = "Recently Played" } @@ -17,7 +17,7 @@ struct LibraryView: View { @State private var sortOrder: LibrarySortOrder = .default private let columns = [ - GridItem(.adaptive(minimum: 220, maximum: 260), spacing: 40) + GridItem(.adaptive(minimum: 220, maximum: 260), spacing: 40), ] private var filteredGames: [GameInfo] { @@ -42,10 +42,10 @@ struct LibraryView: View { var body: some View { ZStack { Color.black.ignoresSafeArea() - if games.isEmpty && viewModel.isLoading { + if games.isEmpty, viewModel.isLoading { ScrollView { LazyVGrid(columns: columns, spacing: 40) { - ForEach(0..<12, id: \.self) { _ in + ForEach(0 ..< 12, id: \.self) { _ in GameCardSkeleton() } } @@ -83,39 +83,39 @@ struct LibraryView: View { } label: { GameCardLabel(game: game) } - .aspectRatio(2/3, contentMode: .fit) + .aspectRatio(2 / 3, contentMode: .fit) #if os(tvOS) - .buttonStyle(.card) + .buttonStyle(.card) #else - .buttonStyle(.plain) + .buttonStyle(.plain) #endif - .contextMenu { - Button { - viewModel.toggleFavorite(game.id) - } label: { - let isFav = viewModel.favoriteIds.contains(game.id) - Label( - isFav ? "Remove from Favorites" : "Add to Favorites", - systemImage: isFav ? "star.slash.fill" : "star" - ) - } - if game.variants.count > 1 { - Menu("Launch via...") { - ForEach(game.variants, id: \.id) { variant in - Button { - viewModel.setPreferredStore(gameId: game.id, variantId: variant.id) - } label: { - let isSelected = viewModel.preferredVariantId(for: game) == variant.id - if isSelected { - Label(variant.storeName, systemImage: "checkmark") - } else { - Text(variant.storeName) + .contextMenu { + Button { + viewModel.toggleFavorite(game.id) + } label: { + let isFav = viewModel.favoriteIds.contains(game.id) + Label( + isFav ? "Remove from Favorites" : "Add to Favorites", + systemImage: isFav ? "star.slash.fill" : "star" + ) + } + if game.variants.count > 1 { + Menu("Launch via...") { + ForEach(game.variants, id: \.id) { variant in + Button { + viewModel.setPreferredStore(gameId: game.id, variantId: variant.id) + } label: { + let isSelected = viewModel.preferredVariantId(for: game) == variant.id + if isSelected { + Label(variant.storeName, systemImage: "checkmark") + } else { + Text(variant.storeName) + } } } } } } - } } } .padding(60) @@ -155,12 +155,12 @@ struct GameBoxArt: View { var body: some View { AsyncImage(url: url.flatMap { URL(string: $0) }) { phase in switch phase { - case .success(let image): - image.resizable().aspectRatio(2/3, contentMode: .fill) + case let .success(image): + image.resizable().aspectRatio(2 / 3, contentMode: .fill) case .failure: Rectangle() .fill(Color.gray.opacity(0.2)) - .aspectRatio(2/3, contentMode: .fit) + .aspectRatio(2 / 3, contentMode: .fit) .shimmer() .onAppear { guard attempt < 3 else { return } @@ -172,10 +172,10 @@ struct GameBoxArt: View { case .empty: Rectangle() .fill(Color.gray.opacity(0.2)) - .aspectRatio(2/3, contentMode: .fit) + .aspectRatio(2 / 3, contentMode: .fit) .shimmer() @unknown default: - Color.gray.opacity(0.2).aspectRatio(2/3, contentMode: .fit) + Color.gray.opacity(0.2).aspectRatio(2 / 3, contentMode: .fit) } } .id(attempt) diff --git a/CloudNow/UI/MainTabView.swift b/CloudNow/UI/MainTabView.swift index bace458..66bdc71 100644 --- a/CloudNow/UI/MainTabView.swift +++ b/CloudNow/UI/MainTabView.swift @@ -7,8 +7,8 @@ struct MainTabView: View { @State private var sessionToResume: ActiveSessionInfo? = nil @State private var directSessionToResume: SessionInfo? = nil #if os(visionOS) - @Environment(\.openImmersiveSpace) var openImmersiveSpace - @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace + @Environment(\.openImmersiveSpace) var openImmersiveSpace + @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace #endif var body: some View { @@ -65,26 +65,26 @@ struct MainTabView: View { } #else .fullScreenCover(item: $gameToPlay) { game in - StreamView( - game: game, - settings: viewModel.streamSettings, - existingSession: sessionToResume, - directSession: directSessionToResume, - onDismiss: { - gameToPlay = nil - sessionToResume = nil - }, - onLeave: { leftGame, session in - viewModel.resumableSession = ResumableSession( - game: leftGame, - session: session, - leftAt: Date() + StreamView( + game: game, + settings: viewModel.streamSettings, + existingSession: sessionToResume, + directSession: directSessionToResume, + onDismiss: { + gameToPlay = nil + sessionToResume = nil + }, + onLeave: { leftGame, session in + viewModel.resumableSession = ResumableSession( + game: leftGame, + session: session, + leftAt: Date() + ) + } ) + .environment(authManager) + .environment(viewModel) } - ) - .environment(authManager) - .environment(viewModel) - } #endif } diff --git a/CloudNow/UI/SettingsView.swift b/CloudNow/UI/SettingsView.swift index 3dd67e5..f0b2603 100644 --- a/CloudNow/UI/SettingsView.swift +++ b/CloudNow/UI/SettingsView.swift @@ -6,7 +6,7 @@ struct SettingsView: View { @State private var showZonePicker = false #if os(visionOS) - @AppStorage("gfn.immersionStyle") private var immersionStyleRaw: String = "full" + @AppStorage("gfn.immersionStyle") private var immersionStyleRaw: String = "full" #endif var body: some View { @@ -179,27 +179,15 @@ struct SettingsView: View { } #if os(visionOS) - Section("Display") { - Picker("Immersion Style", selection: $immersionStyleRaw) { - Text("Full (Cinema)").tag("full") - Text("Mixed (Floating)").tag("mixed") - } - Text("Full replaces your surroundings with a cinema-black background. Mixed keeps the game floating in your real environment. You can also switch between them with the Digital Crown while streaming.") - .font(.caption) - .foregroundStyle(.secondary) - } - #endif - - #if os(visionOS) - Section("Display") { - Picker("Immersion Style", selection: $immersionStyleRaw) { - Text("Full (Cinema)").tag("full") - Text("Mixed (Floating)").tag("mixed") + Section("Display") { + Picker("Immersion Style", selection: $immersionStyleRaw) { + Text("Full (Cinema)").tag("full") + Text("Mixed (Floating)").tag("mixed") + } + Text("Full replaces your surroundings with a cinema-black background. Mixed keeps the game floating in your real environment. You can also switch between them with the Digital Crown while streaming.") + .font(.caption) + .foregroundStyle(.secondary) } - Text("Full replaces your surroundings with a cinema-black background. Mixed keeps the game floating in your real environment. You can also switch between them with the Digital Crown while streaming.") - .font(.caption) - .foregroundStyle(.secondary) - } #endif Section("Controller") { diff --git a/CloudNow/UI/StoreView.swift b/CloudNow/UI/StoreView.swift index 666e894..d8b0262 100644 --- a/CloudNow/UI/StoreView.swift +++ b/CloudNow/UI/StoreView.swift @@ -12,7 +12,7 @@ struct StoreView: View { @State private var selectedStore: String? = nil private var availableStores: [String] { - let stores = Set(games.flatMap { $0.variants.map { $0.appStore } } + let stores = Set(games.flatMap { $0.variants.map(\.appStore) } .filter { $0 != "unknown" }) return stores.sorted() } @@ -29,16 +29,16 @@ struct StoreView: View { } private let columns = [ - GridItem(.adaptive(minimum: 220, maximum: 260), spacing: 40) + GridItem(.adaptive(minimum: 220, maximum: 260), spacing: 40), ] var body: some View { ZStack { Color.black.ignoresSafeArea() - if games.isEmpty && viewModel.isLoading { + if games.isEmpty, viewModel.isLoading { ScrollView { LazyVGrid(columns: columns, spacing: 40) { - ForEach(0..<12, id: \.self) { _ in + ForEach(0 ..< 12, id: \.self) { _ in GameCardSkeleton() } } @@ -53,7 +53,7 @@ struct StoreView: View { } .searchable(text: $searchText, prompt: "Search games") .alert("Not in Your Library", isPresented: $showNotOwned, presenting: notOwnedGame) { _ in - Button("OK") { } + Button("OK") {} } message: { game in Text("\(game.title) is not in your GeForce NOW library. Add it via the GeForce NOW store on another device.") } @@ -89,41 +89,41 @@ struct StoreView: View { } label: { StoreCardLabel(game: game) } - .aspectRatio(2/3, contentMode: .fit) + .aspectRatio(2 / 3, contentMode: .fit) #if os(tvOS) - .buttonStyle(.card) + .buttonStyle(.card) #else - .buttonStyle(.plain) + .buttonStyle(.plain) #endif - .contextMenu { - if game.isInLibrary { - Button { - viewModel.toggleFavorite(game.id) - } label: { - let isFav = viewModel.favoriteIds.contains(game.id) - Label( - isFav ? "Remove from Favorites" : "Add to Favorites", - systemImage: isFav ? "star.slash.fill" : "star" - ) - } - if game.variants.count > 1 { - Menu("Launch via...") { - ForEach(game.variants, id: \.id) { variant in - Button { - viewModel.setPreferredStore(gameId: game.id, variantId: variant.id) - } label: { - let isSelected = viewModel.preferredVariantId(for: game) == variant.id - if isSelected { - Label(variant.storeName, systemImage: "checkmark") - } else { - Text(variant.storeName) + .contextMenu { + if game.isInLibrary { + Button { + viewModel.toggleFavorite(game.id) + } label: { + let isFav = viewModel.favoriteIds.contains(game.id) + Label( + isFav ? "Remove from Favorites" : "Add to Favorites", + systemImage: isFav ? "star.slash.fill" : "star" + ) + } + if game.variants.count > 1 { + Menu("Launch via...") { + ForEach(game.variants, id: \.id) { variant in + Button { + viewModel.setPreferredStore(gameId: game.id, variantId: variant.id) + } label: { + let isSelected = viewModel.preferredVariantId(for: game) == variant.id + if isSelected { + Label(variant.storeName, systemImage: "checkmark") + } else { + Text(variant.storeName) + } } } } } } } - } } } .padding(60) diff --git a/CloudNow/UI/StreamView.swift b/CloudNow/UI/StreamView.swift index 90469ae..4782e66 100644 --- a/CloudNow/UI/StreamView.swift +++ b/CloudNow/UI/StreamView.swift @@ -159,21 +159,21 @@ struct StreamView: View { } #if !os(tvOS) - // On visionOS there is no Siri Remote, so provide an explicit menu button. - // Shown only when the overlay is hidden so it doesn't compete with pause menu controls. - if !showOverlay { - Button { - toggleOverlay() - } label: { - Image(systemName: "line.3.horizontal") - .font(.title2.weight(.semibold)) - .foregroundStyle(.white) - .padding(12) - .background(.black.opacity(0.55), in: Circle()) + // On visionOS there is no Siri Remote, so provide an explicit menu button. + // Shown only when the overlay is hidden so it doesn't compete with pause menu controls. + if !showOverlay { + Button { + toggleOverlay() + } label: { + Image(systemName: "line.3.horizontal") + .font(.title2.weight(.semibold)) + .foregroundStyle(.white) + .padding(12) + .background(.black.opacity(0.55), in: Circle()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(20) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .padding(20) - } #endif } .animation(.easeInOut(duration: 0.4), value: streamController.timeWarning) @@ -182,9 +182,9 @@ struct StreamView: View { // Pause game input while overlay is open in gamepad mode so D-pad // navigates overlay buttons instead of moving the in-game character. #if os(tvOS) - streamController.setInputPaused(showing && streamController.remoteMode != .mouse) + streamController.setInputPaused(showing && streamController.remoteMode != .mouse) #else - streamController.setInputPaused(showing) + streamController.setInputPaused(showing) #endif } .alert("End Session?", isPresented: $showExitConfirmation) { @@ -211,14 +211,14 @@ struct StreamView: View { .tint(.green) #if os(tvOS) - Button { - streamController.toggleRemoteMode() - } label: { - Label(remoteModeLabel, systemImage: remoteModeIcon) - .frame(minWidth: 180) - } - .buttonStyle(.borderedProminent) - .tint(.white) + Button { + streamController.toggleRemoteMode() + } label: { + Label(remoteModeLabel, systemImage: remoteModeIcon) + .frame(minWidth: 180) + } + .buttonStyle(.borderedProminent) + .tint(.white) #endif Button { @@ -361,21 +361,21 @@ struct StreamView: View { } #if os(tvOS) - private var remoteModeLabel: String { - switch streamController.remoteMode { - case .mouse: "Remote: Mouse" - case .gamepad: "Remote: Gamepad" - case .dualsense: "Remote: DualSense" + private var remoteModeLabel: String { + switch streamController.remoteMode { + case .mouse: "Remote: Mouse" + case .gamepad: "Remote: Gamepad" + case .dualsense: "Remote: DualSense" + } } - } - private var remoteModeIcon: String { - switch streamController.remoteMode { - case .mouse: "cursorarrow" - case .gamepad: "gamecontroller" - case .dualsense: "hand.point.up.left" + private var remoteModeIcon: String { + switch streamController.remoteMode { + case .mouse: "cursorarrow" + case .gamepad: "gamecontroller" + case .dualsense: "hand.point.up.left" + } } - } #endif private func metricRow(icon: String, label: String, value: String, history: [Double], color: Color) -> some View { diff --git a/CloudNow/Video/VideoSurfaceView.swift b/CloudNow/Video/VideoSurfaceView.swift index 7172def..ee7ac7f 100644 --- a/CloudNow/Video/VideoSurfaceView.swift +++ b/CloudNow/Video/VideoSurfaceView.swift @@ -15,11 +15,16 @@ import UIKit /// input, forwarding events to `inputHandler` as GFN protocol packets. final class VideoSurfaceView: UIView { #if os(tvOS) - // swiftlint:disable:next force_cast - reason: layerClass guarantees AVSampleBufferDisplayLayer backing - override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self } - private var displayLayer: AVSampleBufferDisplayLayer { layer as! AVSampleBufferDisplayLayer } + // swiftlint:disable:next force_cast - reason: layerClass guarantees AVSampleBufferDisplayLayer backing + override class var layerClass: AnyClass { + AVSampleBufferDisplayLayer.self + } + + private var displayLayer: AVSampleBufferDisplayLayer { + layer as! AVSampleBufferDisplayLayer + } #else - private let displayLayer = AVSampleBufferDisplayLayer() + private let displayLayer = AVSampleBufferDisplayLayer() #endif private let pipelineDiagnostics = VideoPipelineDiagnostics() @@ -81,16 +86,16 @@ final class VideoSurfaceView: UIView { // On visionOS the displayLayer is a sublayer, so its frame must track the view bounds. #if !os(tvOS) - override func layoutSubviews() { - super.layoutSubviews() - displayLayer.frame = bounds - } + override func layoutSubviews() { + super.layoutSubviews() + displayLayer.frame = bounds + } #endif private func setup() { backgroundColor = .black #if !os(tvOS) - layer.addSublayer(displayLayer) + layer.addSublayer(displayLayer) #endif displayLayer.videoGravity = .resizeAspectFill displayLayer.controlTimebase = nil