diff --git a/ios/OpenNOWiOS/OpenNOWiOS.xcodeproj/project.pbxproj b/ios/OpenNOWiOS/OpenNOWiOS.xcodeproj/project.pbxproj index eef30b16..9f2ddc6b 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS.xcodeproj/project.pbxproj +++ b/ios/OpenNOWiOS/OpenNOWiOS.xcodeproj/project.pbxproj @@ -341,7 +341,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; "ASSETCATALOG_COMPILER_APPICON_NAME[sdk=appletvos*]" = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = VR766AGP7G; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -377,7 +377,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; "ASSETCATALOG_COMPILER_APPICON_NAME[sdk=appletvos*]" = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = VR766AGP7G; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -412,7 +412,7 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 69; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = VR766AGP7G; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = OpenNOWWidget/Info.plist; @@ -441,7 +441,7 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 69; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = VR766AGP7G; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = OpenNOWWidget/Info.plist; diff --git a/ios/OpenNOWiOS/OpenNOWiOS/BrowseView.swift b/ios/OpenNOWiOS/OpenNOWiOS/BrowseView.swift index f22ed78b..85b011cc 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/BrowseView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/BrowseView.swift @@ -5,6 +5,7 @@ struct BrowseView: View { @State private var selectedGenre: String? = nil @State private var selectedPlatform: String? @State private var selectedStore: String? + @State private var favoritesOnly = false @State private var sortMode: CatalogSortMode = .title @State private var pendingLaunchRequest: GameLaunchRequest? @State private var selectedGameForDetails: CloudGame? @@ -27,7 +28,8 @@ struct BrowseView: View { let matchesGenre = selectedGenre == nil || game.genre == selectedGenre let matchesPlatform = selectedPlatform == nil || game.platform == selectedPlatform let matchesStore = selectedStore.map { gameResolvedStores(game: game).contains($0) } ?? true - return matchesGenre && matchesPlatform && matchesStore + let matchesFavorite = !favoritesOnly || store.isFavorite(game) + return matchesGenre && matchesPlatform && matchesStore && matchesFavorite } switch sortMode { @@ -55,6 +57,7 @@ struct BrowseView: View { selectedGenre != nil || selectedPlatform != nil || selectedStore != nil || + favoritesOnly || sortMode != .title } @@ -129,6 +132,16 @@ struct BrowseView: View { } .buttonStyle(.bordered) + Button { + Haptics.selection() + favoritesOnly.toggle() + } label: { + Label("Favorites", systemImage: favoritesOnly ? "heart.fill" : "heart") + .lineLimit(1) + } + .buttonStyle(.bordered) + .tint(favoritesOnly ? .red : nil) + if hasActiveFilters { Button { Haptics.light() @@ -151,6 +164,7 @@ struct BrowseView: View { selectedGenre = nil selectedPlatform = nil selectedStore = nil + favoritesOnly = false sortMode = .title } diff --git a/ios/OpenNOWiOS/OpenNOWiOS/ContentView.swift b/ios/OpenNOWiOS/OpenNOWiOS/ContentView.swift index 76beab54..428fd73a 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/ContentView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/ContentView.swift @@ -138,6 +138,7 @@ struct MainTabView: View { .animation(.easeInOut(duration: 0.28), value: store.showStreamLoading && !store.queueOverlayVisible) .animation(.easeInOut(duration: 0.2), value: presentedStreamerSession?.id) .onAppear { + persistQueuePillEdgeIfNeeded() // MainTabView can be recreated by upstream auth/bootstrap state updates. // Reattach streamer overlay if store already has an active stream session. if let activeStream = store.streamSession { @@ -182,6 +183,7 @@ struct MainTabView: View { .padding(.bottom, queuePillEdge == .bottom ? bottomQueuePillPadding(in: proxy) : 0) .queuePillDrag( edgeRawValue: $queuePillVerticalEdgeRaw, + defaultEdgeRawValue: queuePillEdge.rawValue, proxy: proxy, animation: queueSurfaceAnimation ) @@ -214,6 +216,11 @@ struct MainTabView: View { return 12 #endif } + + private func persistQueuePillEdgeIfNeeded() { + guard QueuePillVerticalEdge(rawValue: queuePillVerticalEdgeRaw) == nil else { return } + queuePillVerticalEdgeRaw = queuePillEdge.rawValue + } } private struct QueueStatusPill: View { @@ -333,11 +340,21 @@ private struct QueuePillBackgroundModifier: ViewModifier { private struct QueuePillDragModifier: ViewModifier { @Binding var edgeRawValue: String + let defaultEdgeRawValue: String let proxy: GeometryProxy let animation: Animation @State private var dragOffset: CGFloat = 0 @State private var latchedDuringDrag = false + private var currentEdgeRawValue: String { + switch edgeRawValue { + case "top", "bottom": + return edgeRawValue + default: + return defaultEdgeRawValue + } + } + func body(content: Content) -> some View { #if os(iOS) content @@ -347,7 +364,7 @@ private struct QueuePillDragModifier: ViewModifier { .onChanged { value in guard !latchedDuringDrag else { return } - let currentEdge = edgeRawValue.isEmpty ? "top" : edgeRawValue + let currentEdge = currentEdgeRawValue let snapDistance = min(max(proxy.size.height * 0.12, 68), 118) let translation = value.translation.height let nextEdge: String? @@ -385,7 +402,7 @@ private struct QueuePillDragModifier: ViewModifier { return } - let currentEdge = edgeRawValue.isEmpty ? "top" : edgeRawValue + let currentEdge = currentEdgeRawValue let projectedTranslation = value.translation.height + (value.predictedEndTranslation.height * 0.18) let snapDistance = min(max(proxy.size.height * 0.12, 68), 118) let nextEdge: String? @@ -397,9 +414,7 @@ private struct QueuePillDragModifier: ViewModifier { nextEdge = nil } withAnimation(animation) { - if let nextEdge { - edgeRawValue = nextEdge - } + edgeRawValue = nextEdge ?? currentEdge dragOffset = 0 } } @@ -430,10 +445,18 @@ extension View { @ViewBuilder func queuePillDrag( edgeRawValue: Binding, + defaultEdgeRawValue: String, proxy: GeometryProxy, animation: Animation ) -> some View { - modifier(QueuePillDragModifier(edgeRawValue: edgeRawValue, proxy: proxy, animation: animation)) + modifier( + QueuePillDragModifier( + edgeRawValue: edgeRawValue, + defaultEdgeRawValue: defaultEdgeRawValue, + proxy: proxy, + animation: animation + ) + ) } } diff --git a/ios/OpenNOWiOS/OpenNOWiOS/HomeView.swift b/ios/OpenNOWiOS/OpenNOWiOS/HomeView.swift index 230b4379..39a5e380 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/HomeView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/HomeView.swift @@ -124,6 +124,11 @@ struct HomeView: View { jumpBackInSection } + if !store.favoriteGames.isEmpty { + sectionHeader("Favorites") + favoritesSection + } + if !store.featuredGames.isEmpty || store.isLoadingGames { sectionHeader("Featured") featuredSection @@ -220,6 +225,19 @@ struct HomeView: View { } } + private var favoritesSection: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 14) { + ForEach(store.favoriteGames.prefix(12)) { game in + FeaturedGameCard(game: game) { + selectedGameForDetails = game + } + } + } + .padding(.horizontal) + } + } + private func gameGrid(games: [CloudGame]) -> some View { let columns = [GridItem(.adaptive(minimum: 150, maximum: 200), spacing: 14)] return LazyVGrid(columns: columns, spacing: 14) { @@ -470,6 +488,7 @@ struct GameCardView: View { struct GameLaunchDetailsSheet: View { let game: CloudGame let onLaunch: (GameLaunchOption?) -> Void + @EnvironmentObject private var store: OpenNOWStore @Environment(\.dismiss) private var dismiss @State private var selectedOption: GameLaunchOption? @@ -503,6 +522,7 @@ struct GameLaunchDetailsSheet: View { ScrollView { VStack(alignment: .leading, spacing: 20) { detailHero + launchOptionsSection VStack(alignment: .leading, spacing: 12) { Text("Overview") @@ -553,40 +573,6 @@ struct GameLaunchDetailsSheet: View { .foregroundStyle(.secondary) } - if !launcherOptions.isEmpty { - VStack(alignment: .leading, spacing: 12) { - Text("Launch With") - .font(.headline) - ForEach(launcherOptions) { option in - Button { - Haptics.selection() - selectedOption = option - } label: { - HStack(spacing: 12) { - StoreGlyph(store: option.storefront) - .frame(width: 34, height: 34) - VStack(alignment: .leading, spacing: 2) { - Text(option.storefront.capitalized) - .font(.subheadline.weight(.semibold)) - Text(launchOptionSubtitle(option)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - if selectedOption?.id == option.id { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(brandAccent) - } - } - .padding(14) - .glassCard() - } - .buttonStyle(.plain) - } - } - } - if !detailLabels.isEmpty { VStack(alignment: .leading, spacing: 12) { Text("Features") @@ -609,6 +595,17 @@ struct GameLaunchDetailsSheet: View { } .navigationTitle("Game Details") .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + Haptics.selection() + store.toggleFavorite(game) + } label: { + Image(systemName: store.isFavorite(game) ? "heart.fill" : "heart") + .font(.headline.weight(.semibold)) + .foregroundStyle(store.isFavorite(game) ? .red : .primary) + } + .accessibilityLabel(store.isFavorite(game) ? "Remove from favorites" : "Add to favorites") + } ToolbarItem(placement: .topBarTrailing) { Button { Haptics.light() @@ -681,6 +678,43 @@ struct GameLaunchDetailsSheet: View { [GridItem(.adaptive(minimum: 150, maximum: 220), spacing: 12)] } + @ViewBuilder + private var launchOptionsSection: some View { + if !launcherOptions.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Launch With") + .font(.headline) + ForEach(launcherOptions) { option in + Button { + Haptics.selection() + selectedOption = option + } label: { + HStack(spacing: 12) { + StoreGlyph(store: option.storefront) + .frame(width: 34, height: 34) + VStack(alignment: .leading, spacing: 2) { + Text(option.storefront.capitalized) + .font(.subheadline.weight(.semibold)) + Text(launchOptionSubtitle(option)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if selectedOption?.id == option.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(brandAccent) + } + } + .padding(14) + .glassCard() + } + .buttonStyle(.plain) + } + } + } + } + private var detailHero: some View { ZStack(alignment: .bottomLeading) { GameArtworkView(game: game, iconSize: 44) @@ -755,6 +789,7 @@ struct GameLaunchDetailsSheet: View { } private struct GameArtworkCard: View { + @EnvironmentObject private var store: OpenNOWStore let game: CloudGame let artworkHeight: CGFloat let titleFont: Font @@ -815,6 +850,16 @@ private struct GameArtworkCard: View { } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } + .overlay(alignment: .topTrailing) { + if store.isFavorite(game) { + Image(systemName: "heart.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .padding(8) + .background(.red.opacity(0.88), in: Circle()) + .padding(10) + } + } .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) .stroke(Color.white.opacity(0.08), lineWidth: 1) @@ -1138,6 +1183,7 @@ func gameResolvedStores(game: CloudGame) -> [String] { #if !os(tvOS) private struct UIKitGameDetailsPresenter: UIViewControllerRepresentable { + @EnvironmentObject private var store: OpenNOWStore @Binding var selectedGame: CloudGame? let onLaunch: (CloudGame, GameLaunchOption?) -> Void @@ -1161,6 +1207,7 @@ private struct UIKitGameDetailsPresenter: UIViewControllerRepresentable { onLaunch(game, option) selectedGame = nil } + .environmentObject(store) ) hosted.modalPresentationStyle = .pageSheet if let sheet = hosted.sheetPresentationController { diff --git a/ios/OpenNOWiOS/OpenNOWiOS/LibraryView.swift b/ios/OpenNOWiOS/OpenNOWiOS/LibraryView.swift index e6340911..a315169c 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/LibraryView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/LibraryView.swift @@ -7,6 +7,7 @@ struct LibraryView: View { @State private var searchText = "" @State private var selectedGenre: String? @State private var selectedPlatform: String? + @State private var favoritesOnly = false @State private var sortMode: LibrarySortMode = .title var body: some View { @@ -206,6 +207,16 @@ struct LibraryView: View { } .buttonStyle(.bordered) + Button { + Haptics.selection() + favoritesOnly.toggle() + } label: { + Label("Favorites", systemImage: favoritesOnly ? "heart.fill" : "heart") + .lineLimit(1) + } + .buttonStyle(.bordered) + .tint(favoritesOnly ? .red : nil) + if hasActiveFilters { Button { Haptics.light() @@ -301,7 +312,8 @@ struct LibraryView: View { let matchesGenre = selectedGenre == nil || game.genre == selectedGenre let matchesPlatform = selectedPlatform == nil || game.platform == selectedPlatform - return matchesSearch && matchesGenre && matchesPlatform + let matchesFavorite = !favoritesOnly || store.isFavorite(game) + return matchesSearch && matchesGenre && matchesPlatform && matchesFavorite } switch sortMode { @@ -353,6 +365,7 @@ struct LibraryView: View { !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || selectedGenre != nil || selectedPlatform != nil || + favoritesOnly || sortMode != .title } @@ -391,6 +404,7 @@ struct LibraryView: View { searchText = "" selectedGenre = nil selectedPlatform = nil + favoritesOnly = false sortMode = .title } diff --git a/ios/OpenNOWiOS/OpenNOWiOS/LoginView.swift b/ios/OpenNOWiOS/OpenNOWiOS/LoginView.swift index 730dd202..de1bd1e2 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/LoginView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/LoginView.swift @@ -3,9 +3,6 @@ import SwiftUI struct LoginView: View { @EnvironmentObject private var store: OpenNOWStore - #if os(tvOS) - @Environment(\.webAuthenticationSession) private var webAuthenticationSession - #endif var body: some View { ZStack { @@ -91,7 +88,7 @@ struct LoginView: View { #if os(tvOS) VStack(alignment: .leading, spacing: 12) { - Text("Apple TV sign-in is still being debugged here. Recent auth logs appear below.") + Text("Use your NVIDIA account to sync your library and start cloud sessions on Apple TV.") .font(.footnote) .foregroundStyle(.white.opacity(0.7)) .multilineTextAlignment(.center) @@ -231,15 +228,7 @@ struct LoginView: View { private func handleSignIn() { Haptics.medium() #if os(tvOS) - Task { - await store.signInOnTVOS { url, callbackScheme in - try await webAuthenticationSession.authenticate( - using: url, - callbackURLScheme: callbackScheme, - preferredBrowserSession: .shared - ) - } - } + Task { await store.signIn() } #else Task { await store.signIn() } #endif diff --git a/ios/OpenNOWiOS/OpenNOWiOS/OpenNOWStore.swift b/ios/OpenNOWiOS/OpenNOWiOS/OpenNOWStore.swift index 4a054d0d..5a97cd7f 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/OpenNOWStore.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/OpenNOWStore.swift @@ -158,61 +158,105 @@ struct SessionAdState: Codable, Equatable { struct AppSettings: Codable, Equatable { var preferredRegion: String + var preferredResolution: String var preferredFPS: Int var preferredQuality: String var preferredCodec: String + var maxBitrateMbps: Int + var keyboardLayout: String + var gameLanguage: String + var enableL4S: Bool + var enableCloudGsync: Bool var keepMicEnabled: Bool var showStatsOverlay: Bool + var hideServerSelector: Bool + var queueLiveActivitiesEnabled: Bool var selectedProviderIdpId: String var fortnitePrefersNativeTouch: Bool var touchControlLayouts: [String: TouchControlLayout] var streamerPreferences: StreamerPreferences + var favoriteGameIds: [String] enum CodingKeys: String, CodingKey { case preferredRegion + case preferredResolution case preferredFPS case preferredQuality case preferredCodec + case maxBitrateMbps + case keyboardLayout + case gameLanguage + case enableL4S + case enableCloudGsync case keepMicEnabled case showStatsOverlay + case hideServerSelector + case queueLiveActivitiesEnabled case selectedProviderIdpId case fortnitePrefersNativeTouch case touchControlLayouts case streamerPreferences + case favoriteGameIds } init( preferredRegion: String, + preferredResolution: String, preferredFPS: Int, preferredQuality: String, preferredCodec: String, + maxBitrateMbps: Int, + keyboardLayout: String, + gameLanguage: String, + enableL4S: Bool, + enableCloudGsync: Bool, keepMicEnabled: Bool, showStatsOverlay: Bool, + hideServerSelector: Bool, + queueLiveActivitiesEnabled: Bool, selectedProviderIdpId: String, fortnitePrefersNativeTouch: Bool, touchControlLayouts: [String: TouchControlLayout], - streamerPreferences: StreamerPreferences + streamerPreferences: StreamerPreferences, + favoriteGameIds: [String] ) { self.preferredRegion = preferredRegion + self.preferredResolution = preferredResolution self.preferredFPS = preferredFPS self.preferredQuality = preferredQuality self.preferredCodec = preferredCodec + self.maxBitrateMbps = maxBitrateMbps + self.keyboardLayout = keyboardLayout + self.gameLanguage = gameLanguage + self.enableL4S = enableL4S + self.enableCloudGsync = enableCloudGsync self.keepMicEnabled = keepMicEnabled self.showStatsOverlay = showStatsOverlay + self.hideServerSelector = hideServerSelector + self.queueLiveActivitiesEnabled = queueLiveActivitiesEnabled self.selectedProviderIdpId = selectedProviderIdpId self.fortnitePrefersNativeTouch = fortnitePrefersNativeTouch self.touchControlLayouts = touchControlLayouts self.streamerPreferences = streamerPreferences + self.favoriteGameIds = favoriteGameIds } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) preferredRegion = try container.decodeIfPresent(String.self, forKey: .preferredRegion) ?? "Auto" + preferredResolution = try container.decodeIfPresent(String.self, forKey: .preferredResolution) ?? "Auto" preferredFPS = try container.decodeIfPresent(Int.self, forKey: .preferredFPS) ?? 60 preferredQuality = try container.decodeIfPresent(String.self, forKey: .preferredQuality) ?? "Balanced" preferredCodec = try container.decodeIfPresent(String.self, forKey: .preferredCodec) ?? "Auto" + maxBitrateMbps = try container.decodeIfPresent(Int.self, forKey: .maxBitrateMbps) ?? 0 + keyboardLayout = try container.decodeIfPresent(String.self, forKey: .keyboardLayout) ?? "en-US" + gameLanguage = try container.decodeIfPresent(String.self, forKey: .gameLanguage) ?? "en_US" + enableL4S = try container.decodeIfPresent(Bool.self, forKey: .enableL4S) ?? false + enableCloudGsync = try container.decodeIfPresent(Bool.self, forKey: .enableCloudGsync) ?? false keepMicEnabled = try container.decodeIfPresent(Bool.self, forKey: .keepMicEnabled) ?? false showStatsOverlay = try container.decodeIfPresent(Bool.self, forKey: .showStatsOverlay) ?? true + hideServerSelector = try container.decodeIfPresent(Bool.self, forKey: .hideServerSelector) ?? false + queueLiveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .queueLiveActivitiesEnabled) ?? true selectedProviderIdpId = try container.decodeIfPresent(String.self, forKey: .selectedProviderIdpId) ?? "PDiAhv2kJTFeQ7WOPqiQ2tRZ7lGhR2X11dXvM4TZSxg" fortnitePrefersNativeTouch = try container.decodeIfPresent(Bool.self, forKey: .fortnitePrefersNativeTouch) ?? true @@ -220,24 +264,44 @@ struct AppSettings: Codable, Equatable { ?? TouchControlLayout.defaultProfiles streamerPreferences = try container.decodeIfPresent(StreamerPreferences.self, forKey: .streamerPreferences) ?? .default + favoriteGameIds = try container.decodeIfPresent([String].self, forKey: .favoriteGameIds) ?? [] + migrateLegacyTouchControlDefaults() } static let `default` = AppSettings( preferredRegion: "Auto", + preferredResolution: "Auto", preferredFPS: 60, preferredQuality: "Balanced", preferredCodec: "Auto", + maxBitrateMbps: 0, + keyboardLayout: "en-US", + gameLanguage: "en_US", + enableL4S: false, + enableCloudGsync: false, keepMicEnabled: false, showStatsOverlay: true, + hideServerSelector: false, + queueLiveActivitiesEnabled: true, selectedProviderIdpId: "PDiAhv2kJTFeQ7WOPqiQ2tRZ7lGhR2X11dXvM4TZSxg", fortnitePrefersNativeTouch: true, touchControlLayouts: TouchControlLayout.defaultProfiles, - streamerPreferences: .default + streamerPreferences: .default, + favoriteGameIds: [] ) func touchLayout(for profile: String) -> TouchControlLayout { touchControlLayouts[profile] ?? TouchControlLayout.preset(for: profile) } + + mutating func migrateLegacyTouchControlDefaults() { + if touchControlLayouts["default"] == .legacyStandard || touchControlLayouts["default"] == .legacyShrunkStandard { + touchControlLayouts["default"] = .standard + } + if touchControlLayouts["fortnite-mobile"] == .legacyFortniteMobile || touchControlLayouts["fortnite-mobile"] == .legacyShrunkFortniteMobile { + touchControlLayouts["fortnite-mobile"] = .fortniteMobile + } + } } struct StreamerPreferences: Codable, Equatable { @@ -245,6 +309,7 @@ struct StreamerPreferences: Codable, Equatable { var showStatsClock: Bool var showStatsBattery: Bool var touchControllerVisible: Bool + var touchscreenModeEnabled: Bool var physicalControllerPassthrough: Bool enum CodingKeys: String, CodingKey { @@ -252,6 +317,7 @@ struct StreamerPreferences: Codable, Equatable { case showStatsClock case showStatsBattery case touchControllerVisible + case touchscreenModeEnabled case physicalControllerPassthrough } @@ -260,12 +326,14 @@ struct StreamerPreferences: Codable, Equatable { showStatsClock: Bool, showStatsBattery: Bool, touchControllerVisible: Bool, + touchscreenModeEnabled: Bool, physicalControllerPassthrough: Bool ) { self.audioMuted = audioMuted self.showStatsClock = showStatsClock self.showStatsBattery = showStatsBattery self.touchControllerVisible = touchControllerVisible + self.touchscreenModeEnabled = touchscreenModeEnabled self.physicalControllerPassthrough = physicalControllerPassthrough } @@ -275,6 +343,7 @@ struct StreamerPreferences: Codable, Equatable { showStatsClock = try container.decodeIfPresent(Bool.self, forKey: .showStatsClock) ?? Self.default.showStatsClock showStatsBattery = try container.decodeIfPresent(Bool.self, forKey: .showStatsBattery) ?? Self.default.showStatsBattery touchControllerVisible = try container.decodeIfPresent(Bool.self, forKey: .touchControllerVisible) ?? Self.default.touchControllerVisible + touchscreenModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .touchscreenModeEnabled) ?? Self.default.touchscreenModeEnabled physicalControllerPassthrough = try container.decodeIfPresent(Bool.self, forKey: .physicalControllerPassthrough) ?? Self.default.physicalControllerPassthrough } @@ -283,6 +352,7 @@ struct StreamerPreferences: Codable, Equatable { showStatsClock: false, showStatsBattery: false, touchControllerVisible: false, + touchscreenModeEnabled: false, physicalControllerPassthrough: true ) } @@ -373,6 +443,32 @@ struct TouchControlLayout: Codable, Equatable { } static let standard = TouchControlLayout( + scale: 1, + opacity: 0.58, + buttonScale: 1, + stickScale: 1, + topLeft: .init(x: 0.14, y: 0.12), + topCenter: .init(x: 0.50, y: 0.12), + topRight: .init(x: 0.86, y: 0.12), + leftStick: .init(x: 0.18, y: 0.77), + rightCluster: .init(x: 0.83, y: 0.76), + bottomCenter: .init(x: 0.50, y: 0.88) + ) + + static let fortniteMobile = TouchControlLayout( + scale: 1, + opacity: 0.52, + buttonScale: 1, + stickScale: 1, + topLeft: .init(x: 0.16, y: 0.11), + topCenter: .init(x: 0.50, y: 0.11), + topRight: .init(x: 0.84, y: 0.11), + leftStick: .init(x: 0.17, y: 0.77), + rightCluster: .init(x: 0.84, y: 0.75), + bottomCenter: .init(x: 0.50, y: 0.86) + ) + + static let legacyStandard = TouchControlLayout( scale: 1, opacity: 0.58, buttonScale: 1, @@ -385,7 +481,7 @@ struct TouchControlLayout: Codable, Equatable { bottomCenter: .init(x: 0.50, y: 0.92) ) - static let fortniteMobile = TouchControlLayout( + static let legacyFortniteMobile = TouchControlLayout( scale: 1.05, opacity: 0.52, buttonScale: 1.05, @@ -398,6 +494,32 @@ struct TouchControlLayout: Codable, Equatable { bottomCenter: .init(x: 0.50, y: 0.91) ) + static let legacyShrunkStandard = TouchControlLayout( + scale: 0.70, + opacity: 0.58, + buttonScale: 1, + stickScale: 1, + topLeft: .init(x: 0.14, y: 0.12), + topCenter: .init(x: 0.50, y: 0.12), + topRight: .init(x: 0.86, y: 0.12), + leftStick: .init(x: 0.18, y: 0.77), + rightCluster: .init(x: 0.83, y: 0.76), + bottomCenter: .init(x: 0.50, y: 0.88) + ) + + static let legacyShrunkFortniteMobile = TouchControlLayout( + scale: 0.70, + opacity: 0.52, + buttonScale: 1.05, + stickScale: 1, + topLeft: .init(x: 0.16, y: 0.11), + topCenter: .init(x: 0.50, y: 0.11), + topRight: .init(x: 0.84, y: 0.11), + leftStick: .init(x: 0.17, y: 0.77), + rightCluster: .init(x: 0.84, y: 0.75), + bottomCenter: .init(x: 0.50, y: 0.86) + ) + static let defaultProfiles: [String: TouchControlLayout] = [ "default": .standard, "fortnite-mobile": .fortniteMobile @@ -408,13 +530,179 @@ struct TouchControlLayout: Codable, Equatable { } } +struct StreamVideoProfile: Equatable { + let width: Int + let height: Int + let fps: Int + let maxBitrateKbps: Int + + var resolutionString: String { + "\(width)x\(height)" + } +} + +enum StreamSettingsResolver { + static let resolutionOptions: [(value: String, label: String)] = [ + ("Auto", "Auto"), + ("1280x720", "720p"), + ("1920x1080", "1080p"), + ("2560x1440", "1440p"), + ("3840x2160", "4K") + ] + + static let bitrateOptionsMbps: [Int] = [0, 10, 15, 25, 35, 50, 75, 100] + + static let keyboardLayoutOptions: [(value: String, label: String)] = [ + ("en-US", "English (US)"), + ("en-GB", "English (UK)"), + ("de-DE", "German"), + ("fr-FR", "French"), + ("es-ES", "Spanish"), + ("it-IT", "Italian"), + ("pt-BR", "Portuguese (Brazil)"), + ("pl-PL", "Polish"), + ("tr-TR", "Turkish"), + ("ja-JP", "Japanese"), + ("ko-KR", "Korean"), + ("zh-CN", "Chinese (Simplified)") + ] + + static let gameLanguageOptions: [(value: String, label: String)] = [ + ("en_US", "English (US)"), + ("en_GB", "English (UK)"), + ("de_DE", "German"), + ("fr_FR", "French"), + ("es_ES", "Spanish"), + ("es_MX", "Spanish (Latin America)"), + ("it_IT", "Italian"), + ("pt_BR", "Portuguese (Brazil)"), + ("pl_PL", "Polish"), + ("tr_TR", "Turkish"), + ("ja_JP", "Japanese"), + ("ko_KR", "Korean"), + ("zh_CN", "Chinese (Simplified)"), + ("zh_TW", "Chinese (Traditional)") + ] + + static func profile(for settings: AppSettings) -> StreamVideoProfile { + #if os(tvOS) + return profile( + for: settings, + nativeBounds: .zero, + nativeScale: 1, + userInterfaceIdiom: .tv + ) + #else + return profile( + for: settings, + nativeBounds: UIScreen.main.nativeBounds, + nativeScale: UIScreen.main.nativeScale, + userInterfaceIdiom: UIDevice.current.userInterfaceIdiom + ) + #endif + } + + static func profile( + for settings: AppSettings, + nativeBounds: CGRect, + nativeScale: CGFloat, + userInterfaceIdiom: UIUserInterfaceIdiom + ) -> StreamVideoProfile { + let fps = normalizedFPS(settings.preferredFPS) + let requestedResolution = parseResolution(settings.preferredResolution) + let base = requestedResolution ?? automaticResolution( + settings: settings, + nativeBounds: nativeBounds, + nativeScale: nativeScale, + userInterfaceIdiom: userInterfaceIdiom + ) + let bitrateMbps = normalizedMaxBitrateMbps(settings.maxBitrateMbps) + ?? automaticBitrateMbps(width: base.width, height: base.height, fps: fps, quality: settings.preferredQuality) + return StreamVideoProfile( + width: base.width, + height: base.height, + fps: fps, + maxBitrateKbps: max(5_000, bitrateMbps * 1_000) + ) + } + + static func normalizedKeyboardLayout(_ value: String) -> String { + keyboardLayoutOptions.contains(where: { $0.value == value }) ? value : "en-US" + } + + static func normalizedGameLanguage(_ value: String) -> String { + gameLanguageOptions.contains(where: { $0.value == value }) ? value : "en_US" + } + + private static func normalizedFPS(_ value: Int) -> Int { + min(max(value, 30), 120) + } + + private static func parseResolution(_ value: String) -> (width: Int, height: Int)? { + guard value != "Auto" else { return nil } + let parts = value.split(separator: "x", maxSplits: 1).map(String.init) + guard parts.count == 2, + let width = Int(parts[0]), + let height = Int(parts[1]), + width > 0, + height > 0 else { + return nil + } + return (width, height) + } + + private static func automaticResolution( + settings: AppSettings, + nativeBounds: CGRect, + nativeScale: CGFloat, + userInterfaceIdiom: UIUserInterfaceIdiom + ) -> (width: Int, height: Int) { + let longSide = max(nativeBounds.width, nativeBounds.height) + let shortSide = min(nativeBounds.width, nativeBounds.height) + let supports1440 = longSide >= 2500 || shortSide >= 1400 || nativeScale >= 3.0 + let prefersQuality = settings.preferredQuality.caseInsensitiveCompare("Quality") == .orderedSame + + if userInterfaceIdiom == .pad, prefersQuality, supports1440 { + return (2560, 1440) + } + if userInterfaceIdiom == .pad || userInterfaceIdiom == .tv { + return (1920, 1080) + } + return (1280, 720) + } + + private static func automaticBitrateMbps(width: Int, height: Int, fps: Int, quality: String) -> Int { + let pixels = width * height + let qualityKey = quality.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let base: Int + + if pixels >= 3840 * 2160 { + base = qualityKey == "data saver" ? 35 : (qualityKey == "quality" ? 75 : 50) + } else if pixels >= 2560 * 1440 { + base = qualityKey == "data saver" ? 18 : (qualityKey == "quality" ? 36 : 28) + } else if pixels >= 1920 * 1080 { + base = qualityKey == "data saver" ? 12 : (qualityKey == "quality" ? 24 : 18) + } else { + base = qualityKey == "data saver" ? 9 : (qualityKey == "quality" ? 18 : 13) + } + + let fpsAdjusted = fps > 60 ? Int((Double(base) * 1.35).rounded()) : base + return min(max(fpsAdjusted, 5), 100) + } + + private static func normalizedMaxBitrateMbps(_ value: Int) -> Int? { + guard value > 0 else { return nil } + return min(max(value, 5), 150) + } +} + enum OpenNOWPlatform { #if os(tvOS) static let supportsNativeOAuth = true - static let supportsEmbeddedStreamer = false + static let supportsEmbeddedStreamer = true static let displayName = "tvOS" static let authUnavailableReason = "" - static let streamingUnavailableReason = "Apple TV streaming still needs a native tvOS player." + static let streamingUnavailableReason = "" #else static let supportsNativeOAuth = true static let supportsEmbeddedStreamer = true @@ -596,7 +884,7 @@ private final class OAuthWebAuthenticator: NSObject { #if os(tvOS) authSession = ASWebAuthenticationSession( url: url, - callback: .customScheme(callbackScheme) + callbackURLScheme: nil ) { callbackURL, error in if let callbackURL { TVAuthDiagnostics.record("Auth session completed with \(summarizeOAuthCallback(callbackURL))") @@ -631,7 +919,7 @@ private final class OAuthWebAuthenticator: NSObject { authSession.prefersEphemeralWebBrowserSession = false #endif self.session = authSession - TVAuthDiagnostics.record("Starting ASWebAuthenticationSession.") + TVAuthDiagnostics.record("Starting ASWebAuthenticationSession canStart=\(authSession.canStart).") if !authSession.start() { TVAuthDiagnostics.record("ASWebAuthenticationSession failed to start.") continuation.resume(throwing: NSError( @@ -1434,6 +1722,7 @@ private actor GFNAPIClient { game: CloudGame, vpcId: String, settings: AppSettings, + streamProfile: StreamVideoProfile, streamingBaseUrl: String? = nil, launchAppIdOverride: String? = nil, launcherName: String = "Auto" @@ -1446,11 +1735,16 @@ private actor GFNAPIClient { let baseSource = streamingBaseUrl ?? session.provider.streamingServiceUrl let base = baseSource.hasSuffix("/") ? String(baseSource.dropLast()) : baseSource let deviceProfile = Self.streamDeviceProfile(for: game.title, settings: settings) - let url = URL(string: "\(base)/v2/session?keyboardLayout=en-US&languageCode=en_US")! + let sessionQuery = URLQueryItemEncoder.encode([ + "keyboardLayout": StreamSettingsResolver.normalizedKeyboardLayout(settings.keyboardLayout), + "languageCode": StreamSettingsResolver.normalizedGameLanguage(settings.gameLanguage) + ]) + let url = URL(string: "\(base)/v2/session?\(sessionQuery)")! let body = Self.buildSessionBody( appId: launchAppId, title: game.title, - fps: settings.preferredFPS, + settings: settings, + profile: streamProfile, launcherName: launcherName, deviceProfile: deviceProfile ) @@ -1557,6 +1851,19 @@ private actor GFNAPIClient { ((sessionObj["seatSetupInfo"] as? [String: Any])?["queuePosition"] as? Int) let seatSetupStep = Self.extractSeatSetupStep(sessionObj: sessionObj) let serverIp = Self.extractServerIp(sessionObj: sessionObj) ?? activeSession.serverIp + if (status == 2 || status == 3), + let serverIp, + !Self.isZoneHostname(serverIp), + let baseHost = URL(string: base)?.host, + Self.isZoneHostname(baseHost) { + do { + return try await pollSession(session: session, activeSession: activeSession, base: "https://\(serverIp)") + } catch { + // Zone polling still contains usable queue/session state. If direct + // server hydration fails, keep the current response instead of + // dropping the user's active session. + } + } let mediaConnectionInfo = Self.extractMediaConnectionInfo(sessionObj: sessionObj) let signaling = Self.resolveSignaling(sessionObj: sessionObj, fallbackServerIp: serverIp ?? activeSession.signalingServer) let iceServers = Self.extractIceServers(sessionObj: sessionObj) @@ -1743,11 +2050,12 @@ private actor GFNAPIClient { game: CloudGame, streamingBaseUrl: String, vpcId: String, - settings: AppSettings + settings: AppSettings, + deviceId: String ) async throws -> ActiveSession { let token = session.tokens.idToken ?? session.tokens.accessToken let clientId = UUID().uuidString - let deviceId = UUID().uuidString + let claimDeviceId = deviceId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? UUID().uuidString : deviceId let deviceProfile = Self.streamDeviceProfile(for: game.title, settings: settings) let zoneBase = Self.normalizedStreamingBase(streamingBaseUrl, vpcId: vpcId) var effectiveServerIp = Self.remoteSessionTargetHost( @@ -1765,7 +2073,7 @@ private actor GFNAPIClient { headers: Self.cloudMatchHeaders( token: token, clientId: clientId, - deviceId: deviceId, + deviceId: claimDeviceId, includeOrigin: false, deviceProfile: deviceProfile ) @@ -1791,7 +2099,7 @@ private actor GFNAPIClient { headers: Self.cloudMatchHeaders( token: token, clientId: clientId, - deviceId: deviceId, + deviceId: claimDeviceId, includeOrigin: false, deviceProfile: deviceProfile ) @@ -1804,12 +2112,17 @@ private actor GFNAPIClient { var claimJSON: [String: Any] = [:] if preClaimStatus != 1 { - let claimURL = URL(string: "https://\(effectiveServerIp)/v2/session/\(candidate.id)?keyboardLayout=en-US&languageCode=en_US")! + let sessionQuery = URLQueryItemEncoder.encode([ + "keyboardLayout": StreamSettingsResolver.normalizedKeyboardLayout(settings.keyboardLayout), + "languageCode": StreamSettingsResolver.normalizedGameLanguage(settings.gameLanguage) + ]) + let claimURL = URL(string: "https://\(effectiveServerIp)/v2/session/\(candidate.id)?\(sessionQuery)")! let claimBody = Self.buildClaimBody( sessionId: candidate.id, appId: candidate.appId ?? game.launchAppId ?? "0", settings: settings, - deviceProfile: deviceProfile + deviceProfile: deviceProfile, + deviceHashId: claimDeviceId ) let (claimData, claimResponse) = try await request( @@ -1818,7 +2131,7 @@ private actor GFNAPIClient { headers: Self.cloudMatchHeaders( token: token, clientId: clientId, - deviceId: deviceId, + deviceId: claimDeviceId, includeOrigin: true, deviceProfile: deviceProfile ), @@ -1857,7 +2170,7 @@ private actor GFNAPIClient { zone: vpcId, streamingBaseUrl: zoneBase, clientId: clientId, - deviceId: deviceId, + deviceId: claimDeviceId, adState: Self.extractAdState(sessionObj: resolvedSessionObj) ) @@ -2705,7 +3018,7 @@ private actor GFNAPIClient { "nv-client-type": "NATIVE", "nv-client-version": GFNConstants.gfnClientVersion, "nv-device-make": "APPLE", - "nv-device-model": UIDevice.current.model, + "nv-device-model": OpenNOWPlatform.displayName, "nv-device-os": deviceProfile.nvDeviceOS, "nv-device-type": deviceProfile.nvDeviceType, "x-device-id": deviceId @@ -2720,7 +3033,8 @@ private actor GFNAPIClient { private static func buildSessionBody( appId: String, title: String, - fps: Int, + settings: AppSettings, + profile: StreamVideoProfile, launcherName: String, deviceProfile: StreamDeviceProfile ) -> Data { @@ -2731,7 +3045,7 @@ private actor GFNAPIClient { ["key": "networkType", "value": "Unknown"], ["key": "ClientImeSupport", "value": "0"], ["key": "preferredLauncher", "value": launcherName], - ["key": "clientPhysicalResolution", "value": "{\"horizontalPixels\":1920,\"verticalPixels\":1080}"], + ["key": "clientPhysicalResolution", "value": "{\"horizontalPixels\":\(profile.width),\"verticalPixels\":\(profile.height)}"], ["key": "surroundAudioInfo", "value": "2"] ] + deviceProfile.metadata let body: [String: Any] = [ @@ -2748,9 +3062,9 @@ private actor GFNAPIClient { "streamerVersion": 1, "clientPlatformName": deviceProfile.clientPlatformName, "clientRequestMonitorSettings": [[ - "widthInPixels": 1920, - "heightInPixels": 1080, - "framesPerSecond": fps, + "widthInPixels": profile.width, + "heightInPixels": profile.height, + "framesPerSecond": profile.fps, "sdrHdrMode": 0, "displayData": [ "desiredContentMaxLuminance": 0, @@ -2765,7 +3079,7 @@ private actor GFNAPIClient { "sdrHdrMode": 0, "surroundAudioInfo": 0, "remoteControllersBitmap": 0, - "clientTimezoneOffset": -TimeZone.current.secondsFromGMT() * 1000, + "clientTimezoneOffset": TimeZone.current.secondsFromGMT() * 1000, "enhancedStreamMode": 1, "appLaunchMode": 1, "secureRTSPSupported": false, @@ -2774,13 +3088,16 @@ private actor GFNAPIClient { "enablePersistingInGameSettings": true, "userAge": 26, "requestedStreamingFeatures": [ - "reflex": fps >= 120, + "reflex": profile.fps >= 120, "bitDepth": 0, "cloudGsync": false, - "enabledL4S": false, + "enabledL4S": settings.enableL4S, "mouseMovementFlags": 0, "trueHdr": false, + "supportedHidDevices": 0, "profile": 0, + "fallbackToLogicalResolution": false, + "hidDevices": NSNull(), "chromaFormat": 0, "prefilterMode": 0, "prefilterSharpness": 0, @@ -2798,7 +3115,8 @@ private actor GFNAPIClient { sessionId: String, appId: String, settings: AppSettings, - deviceProfile: StreamDeviceProfile + deviceProfile: StreamDeviceProfile, + deviceHashId: String ) -> Data { let metadata: [[String: String]] = [ ["key": "SubSessionId", "value": UUID().uuidString], @@ -2817,12 +3135,12 @@ private actor GFNAPIClient { "networkTestSessionId": NSNull(), "availableSupportedControllers": [], "clientVersion": "30.0", - "deviceHashId": UUID().uuidString, + "deviceHashId": deviceHashId, "internalTitle": NSNull(), "clientPlatformName": deviceProfile.clientPlatformName, "metaData": metadata, "surroundAudioInfo": 0, - "clientTimezoneOffset": -TimeZone.current.secondsFromGMT() * 1000, + "clientTimezoneOffset": TimeZone.current.secondsFromGMT() * 1000, "clientIdentification": deviceProfile.clientIdentification, "parentSessionId": NSNull(), "appId": Int(appId) ?? 0, @@ -2838,10 +3156,9 @@ private actor GFNAPIClient { "secureRTSPSupported": false, "userAge": 26, "requestedStreamingFeatures": [ - "reflex": settings.preferredFPS >= 120, + "reflex": false, "bitDepth": 0, "cloudGsync": false, - "enabledL4S": false, "profile": 0, "fallbackToLogicalResolution": false, "chromaFormat": 0, @@ -2994,24 +3311,36 @@ final class OpenNOWStore: ObservableObject { var supportsEmbeddedStreamer: Bool { OpenNOWPlatform.supportsEmbeddedStreamer } func bootstrap() async { - defer { isBootstrapping = false } - await NotificationManager.shared.requestPermission() - providers = await api.fetchProviders() + guard isBootstrapping else { return } + + if providers.isEmpty { + providers = [GFNConstants.defaultProvider] + } if settings.selectedProviderIdpId.isEmpty { settings.selectedProviderIdpId = providers.first?.idpId ?? GFNConstants.defaultProvider.idpId persistSettings() } - if let existing = authSession { - if let refreshed = try? await api.refreshSession(existing) { - authSession = refreshed - user = refreshed.user - persistAuthSession(refreshed) + syncTrackedSessionSurface() + isBootstrapping = false + + Task { + await NotificationManager.shared.requestPermission() + } + Task { + let fetchedProviders = await api.fetchProviders() + providers = fetchedProviders.isEmpty ? [GFNConstants.defaultProvider] : fetchedProviders + if settings.selectedProviderIdpId.isEmpty { + settings.selectedProviderIdpId = providers.first?.idpId ?? GFNConstants.defaultProvider.idpId + persistSettings() + } + } + if authSession != nil { + Task { await refreshCatalog() restoreTrackedSessionIfNeeded() } } - syncTrackedSessionSurface() } func signIn() async { @@ -3148,6 +3477,7 @@ final class OpenNOWStore: ObservableObject { game: game, vpcId: cachedVpcId, settings: settings, + streamProfile: StreamSettingsResolver.profile(for: settings), streamingBaseUrl: zoneUrl, launchAppIdOverride: launchOption?.appId, launcherName: launchOption?.storefront ?? "Auto" @@ -3247,7 +3577,8 @@ final class OpenNOWStore: ObservableObject { game: game, streamingBaseUrl: refreshed.provider.streamingServiceUrl, vpcId: cachedVpcId, - settings: settings + settings: settings, + deviceId: persistentDeviceId() ) activeSession = claimed adReportStateById = [:] @@ -3445,6 +3776,28 @@ final class OpenNOWStore: ObservableObject { } } + func refreshTrackedSessionSurface() { + syncTrackedSessionSurface() + } + + func isFavorite(_ game: CloudGame) -> Bool { + settings.favoriteGameIds.contains(game.id) + } + + func toggleFavorite(_ game: CloudGame) { + if let index = settings.favoriteGameIds.firstIndex(of: game.id) { + settings.favoriteGameIds.remove(at: index) + } else { + settings.favoriteGameIds.append(game.id) + } + persistSettings() + } + + func clearFavorites() { + settings.favoriteGameIds.removeAll() + persistSettings() + } + func updateTouchControlLayout(_ layout: TouchControlLayout, profile: String) { settings.touchControlLayouts[profile] = layout persistSettings() @@ -3470,6 +3823,10 @@ final class OpenNOWStore: ObservableObject { authProviderCode == "NVIDIA" } + var shouldPresentPrintedWasteQueue: Bool { + shouldUsePrintedWasteQueue && !settings.hideServerSelector + } + func formattedSessionElapsed() -> String { let hours = sessionElapsedSeconds / 3600 let minutes = (sessionElapsedSeconds % 3600) / 60 @@ -3486,10 +3843,26 @@ final class OpenNOWStore: ObservableObject { return allGames.filter { $0.title.localizedCaseInsensitiveContains(query) || $0.genre.localizedCaseInsensitiveContains(query) || - $0.platform.localizedCaseInsensitiveContains(query) + $0.platform.localizedCaseInsensitiveContains(query) || + ($0.summary?.localizedCaseInsensitiveContains(query) ?? false) || + ($0.longDescription?.localizedCaseInsensitiveContains(query) ?? false) || + ($0.publisher?.localizedCaseInsensitiveContains(query) ?? false) || + ($0.developer?.localizedCaseInsensitiveContains(query) ?? false) || + ($0.featureLabels?.contains(where: { $0.localizedCaseInsensitiveContains(query) }) ?? false) || + ($0.tags?.contains(where: { $0.localizedCaseInsensitiveContains(query) }) ?? false) || + ($0.stores?.contains(where: { storeDisplayName($0).localizedCaseInsensitiveContains(query) }) ?? false) } } + var favoriteGames: [CloudGame] { + guard !settings.favoriteGameIds.isEmpty else { return [] } + var gamesById: [String: CloudGame] = [:] + for game in allGames + libraryGames { + gamesById[game.id] = game + } + return settings.favoriteGameIds.compactMap { gamesById[$0] } + } + private func startSessionTasks() { setStreamSession(nil, reason: "startSessionTasks.reset") reopenToken = UUID() @@ -3725,13 +4098,26 @@ final class OpenNOWStore: ObservableObject { let refreshed = try await api.refreshSession(currentAuth) authSession = refreshed persistAuthSession(refreshed) - let polled = try await api.pollSession(session: refreshed, activeSession: session) - let handoff = await prepareSessionForStreamer(polled) + var latest = try await api.pollSession(session: refreshed, activeSession: session) + for attempt in 0..<6 { + if isReadyForStreamer(latest) { + let handoff = await prepareSessionForStreamer(latest) + guard activeSession?.id == session.id else { return } + activeSession = handoff + setStreamSession(handoff, reason: "reopenStreamer.refreshed") + lastError = nil + return + } + logger.info( + "Reopen waiting for ready endpoint id=\(latest.id, privacy: .public) status=\(latest.status) attempt=\(attempt + 1) signalingServer=\(latest.signalingServer ?? "nil", privacy: .public) signalingUrl=\(latest.signalingUrl ?? "nil", privacy: .public) mediaIp=\(latest.mediaIp ?? "nil", privacy: .public) mediaPort=\(latest.mediaPort)" + ) + try await Task.sleep(for: .milliseconds(900)) + latest = try await api.pollSession(session: refreshed, activeSession: latest) + } guard activeSession?.id == session.id else { return } - activeSession = handoff - setStreamSession(handoff, reason: "reopenStreamer.refreshed") - lastError = nil + activeSession = latest + lastError = "Session is still preparing its stream endpoint. Try reopening again in a moment." } catch is CancellationError { return } catch { @@ -3744,7 +4130,7 @@ final class OpenNOWStore: ObservableObject { private var isFreeTierUser: Bool { let tier = (subscription?.membershipTier ?? user?.membershipTier)?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - return ((tier?.isEmpty) != nil) || tier == "FREE" + return (tier?.isEmpty ?? true) || tier == "FREE" } private func reportQueueAdAction( @@ -3839,7 +4225,7 @@ final class OpenNOWStore: ObservableObject { private func syncTrackedSessionSurface() { persistActiveSession(activeSession) let active = activeSession - let state = active.flatMap(queueActivityState(for:)) + let state = settings.queueLiveActivitiesEnabled ? active.flatMap(queueActivityState(for:)) : nil Task { await QueueLiveActivityManager.shared.sync( sessionId: active?.id, @@ -3850,7 +4236,7 @@ final class OpenNOWStore: ObservableObject { } private func queueActivityState(for session: ActiveSession) -> QueueActivityAttributes.ContentState? { - if streamSession != nil && currentScenePhase == .active { + if streamSession != nil { return nil } if isReadyForStreamer(session) || session.status == 3 { @@ -3894,8 +4280,12 @@ final class OpenNOWStore: ObservableObject { private func resolveGameForRemoteSession(_ candidate: RemoteSessionCandidate) -> CloudGame? { if let appId = candidate.appId { - if let fromAll = allGames.first(where: { $0.launchAppId == appId }) { return fromAll } - if let fromLibrary = libraryGames.first(where: { $0.launchAppId == appId }) { return fromLibrary } + if let fromAll = allGames.first(where: { $0.launchAppId == appId || $0.launchOptions.contains(where: { $0.appId == appId }) }) { + return fromAll + } + if let fromLibrary = libraryGames.first(where: { $0.launchAppId == appId || $0.launchOptions.contains(where: { $0.appId == appId }) }) { + return fromLibrary + } } return featuredGames.first ?? allGames.first ?? libraryGames.first } diff --git a/ios/OpenNOWiOS/OpenNOWiOS/PrintedWasteQueueView.swift b/ios/OpenNOWiOS/OpenNOWiOS/PrintedWasteQueueView.swift index 646ed775..93cdc849 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/PrintedWasteQueueView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/PrintedWasteQueueView.swift @@ -106,6 +106,40 @@ struct PrintedWasteQueueView: View { } } + private var selectedRoutingZone: PrintedWasteZone? { + switch routingPreference { + case .auto: + return autoZone + case .closest: + return closestZone ?? autoZone + case .manual: + return zones.first(where: { $0.id == selectedZoneId }) ?? autoZone + } + } + + private var routingExplanation: String { + switch routingPreference { + case .auto: + if let autoZone { + return "Auto will launch on \(zoneDisplayName(autoZone))." + } + return "Auto will pick the best available server once queue data finishes loading." + case .closest: + if let closestZone { + return "Closest will launch on \(zoneDisplayName(closestZone))." + } + if let autoZone { + return "Closest is still measuring; launch will fall back to \(zoneDisplayName(autoZone))." + } + return "Closest is measuring network latency." + case .manual: + if let selectedRoutingZone { + return "Manual selection will launch on \(zoneDisplayName(selectedRoutingZone))." + } + return "Choose a specific server below." + } + } + var body: some View { NavigationStack { Group { @@ -144,6 +178,10 @@ struct PrintedWasteQueueView: View { .font(.headline) .foregroundStyle(.secondary) routingRow + Text(routingExplanation) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } .padding(14) .printedWasteGlassSurface() @@ -223,12 +261,12 @@ struct PrintedWasteQueueView: View { .foregroundStyle(.secondary) } Spacer() - if let autoZone { + if let selectedRoutingZone { VStack(alignment: .trailing, spacing: 2) { - Text("Best") + Text(routingPreference == .closest ? "Closest" : routingPreference == .manual ? "Selected" : "Auto") .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) - Text(autoZone.id) + Text(selectedRoutingZone.id) .font(.caption.weight(.bold)) } } @@ -286,6 +324,11 @@ struct PrintedWasteQueueView: View { .opacity(isEnabled ? 1 : 0.6) } + private func zoneDisplayName(_ zone: PrintedWasteZone) -> String { + let ping = zone.pingMs.map { "\($0) ms" } ?? (zone.isMeasuring ? "measuring" : "ping unknown") + return "\(zone.id) in \(zone.region) · Q \(zone.queuePosition) · \(ping)" + } + private func loadZones() async { isLoading = true fetchError = nil @@ -413,17 +456,11 @@ struct PrintedWasteQueueView: View { await withCheckedContinuation { continuation in let connection = NWConnection(host: NWEndpoint.Host(host), port: port, using: .tcp) let start = Date() - let lock = NSLock() - var didFinish = false + let completionState = TCPProbeCompletionState() + @Sendable func finish(_ sample: Double?) { - lock.lock() - guard !didFinish else { - lock.unlock() - return - } - didFinish = true - lock.unlock() + guard completionState.markFinished() else { return } connection.stateUpdateHandler = nil connection.cancel() @@ -682,14 +719,14 @@ private struct PrintedWasteLaunchSheetModifier: ViewModifier { func body(content: Content) -> some View { let sheetBinding = Binding( get: { - store.shouldUsePrintedWasteQueue ? pendingLaunchRequest : nil + store.shouldPresentPrintedWasteQueue ? pendingLaunchRequest : nil }, set: { pendingLaunchRequest = $0 } ) content .onChange(of: pendingLaunchRequest?.id) { _, _ in - guard !store.shouldUsePrintedWasteQueue, + guard !store.shouldPresentPrintedWasteQueue, let request = pendingLaunchRequest else { return } store.scheduleLaunch(game: request.game, zoneUrl: nil, launchOption: request.launchOption) pendingLaunchRequest = nil @@ -716,6 +753,19 @@ private struct PrintedWasteLaunchSheetModifier: ViewModifier { } } +private final class TCPProbeCompletionState: @unchecked Sendable { + private let lock = NSLock() + private var didFinish = false + + func markFinished() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !didFinish else { return false } + didFinish = true + return true + } +} + private struct PrintedWasteQueueResponse: Decodable { let status: Bool let data: [String: PrintedWasteQueueAPIEntry] diff --git a/ios/OpenNOWiOS/OpenNOWiOS/SessionView.swift b/ios/OpenNOWiOS/OpenNOWiOS/SessionView.swift index 6f3d6c11..e6049b41 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/SessionView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/SessionView.swift @@ -255,18 +255,38 @@ struct SessionView: View { } else { ForEach(store.resumableSessions) { candidate in let isEndingRemote = endingRemoteSessionId == candidate.id - HStack { - VStack(alignment: .leading, spacing: 3) { - Text("Session") - .font(.caption.bold()) - Text(candidate.id) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) + let game = store.gameForRemoteSession(candidate) + HStack(spacing: 12) { + if let game { + GameArtworkView(game: game, iconSize: 24) + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } else { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(.secondary.opacity(0.14)) + Image(systemName: "dot.radiowaves.left.and.right") + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(width: 48, height: 48) + } + + VStack(alignment: .leading, spacing: 5) { + Text(game?.title ?? "Cloud session") + .font(.subheadline.weight(.semibold)) .lineLimit(1) - if let appId = candidate.appId { - Text("App: \(appId)") - .font(.caption2) + HStack(spacing: 6) { + statusDot(status: candidate.status) + Text(statusLabel(candidate.status)) + .font(.caption) .foregroundStyle(.secondary) + if let appId = candidate.appId, game == nil { + Text("App \(appId)") + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } } } Spacer() diff --git a/ios/OpenNOWiOS/OpenNOWiOS/SettingsView.swift b/ios/OpenNOWiOS/OpenNOWiOS/SettingsView.swift index 4f3710cd..4fa19523 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/SettingsView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/SettingsView.swift @@ -3,7 +3,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject private var store: OpenNOWStore - private let fpsValues = [60, 120] + private let fpsValues = [30, 60, 120] private let qualityValues = ["Balanced", "Data Saver", "Quality"] private let codecValues = ["Auto", "H264", "H265", "AV1"] private let regionValues = ["Auto", "US East", "US West", "Europe", "Asia"] @@ -11,157 +11,236 @@ struct SettingsView: View { var body: some View { NavigationStack { Form { - Section { - settingRow( - icon: "globe", color: .blue, - title: "Region", - picker: Picker("Region", selection: $store.settings.preferredRegion) { - ForEach(regionValues, id: \.self) { Text($0).tag($0) } - } - ) - settingRow( - icon: "speedometer", color: .green, - title: "Target FPS", - picker: Picker("FPS", selection: $store.settings.preferredFPS) { - ForEach(fpsValues, id: \.self) { Text("\($0) fps").tag($0) } - } - ) - settingRow( - icon: "slider.horizontal.3", color: .orange, - title: "Quality", - picker: Picker("Quality", selection: $store.settings.preferredQuality) { - ForEach(qualityValues, id: \.self) { Text($0).tag($0) } - } - ) - settingRow( - icon: "video.fill", color: .purple, - title: "Codec", - picker: Picker("Codec", selection: $store.settings.preferredCodec) { - ForEach(codecValues, id: \.self) { Text($0).tag($0) } - } - ) - } header: { - Label("Streaming", systemImage: "dot.radiowaves.left.and.right") + profileSection + streamingSection + sessionSection + inputSection + appSection + dataSection + if let user = store.user { + accountSection(user) } + aboutSection + } + .navigationTitle("Settings") + .tint(.blue) + .onChange(of: store.settings) { _, _ in + store.persistSettings() + } + .onChange(of: store.settings.queueLiveActivitiesEnabled) { _, _ in + store.refreshTrackedSessionSurface() + } + } + } - Section { - HStack { - Label("Keep Microphone On", systemImage: "mic.fill") - Spacer() - Toggle("", isOn: $store.settings.keepMicEnabled) - } - HStack { - Label("Stats Overlay", systemImage: "chart.bar.fill") - Spacer() - Toggle("", isOn: $store.settings.showStatsOverlay) - } - HStack { - Label("Fortnite Mobile Touch", systemImage: "hand.tap.fill") - Spacer() - Toggle("", isOn: $store.settings.fortnitePrefersNativeTouch) - } - HStack { - Label("Bluetooth Controller Passthrough", systemImage: "gamecontroller.fill") - Spacer() - Toggle("", isOn: $store.settings.streamerPreferences.physicalControllerPassthrough) - } - HStack { - Label("Show Touch Controller", systemImage: "circle.grid.cross.fill") - Spacer() - Toggle("", isOn: $store.settings.streamerPreferences.touchControllerVisible) - } - } header: { - Label("Experience", systemImage: "star.fill") - } footer: { - Text("Fortnite mobile touch advertises a mobile device profile. Bluetooth passthrough sends connected controllers using the same gamepad packet path as the desktop streamer.") + private var profileSection: some View { + Section { + HStack(spacing: 14) { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.blue) + + VStack(alignment: .leading, spacing: 3) { + Text(store.user?.displayName ?? "OpenNOW") + .font(.headline) + Text(headerSummary) + .font(.subheadline) + .foregroundStyle(.secondary) } + } + .padding(.vertical, 4) + } + } - Section { - Button { - Task { await store.refreshCatalog() } - } label: { - Text("Reload Catalog") - } - .disabled(store.isLoadingGames) - } header: { - Label("Data", systemImage: "internaldrive.fill") + private var streamingSection: some View { + Section { + Picker(selection: $store.settings.preferredRegion) { + ForEach(regionValues, id: \.self) { Text($0).tag($0) } + } label: { + Label("Region", systemImage: "globe") + } + + Picker(selection: $store.settings.preferredResolution) { + ForEach(StreamSettingsResolver.resolutionOptions, id: \.value) { option in + Text(option.label).tag(option.value) } + } label: { + Label("Resolution", systemImage: "display") + } - if let user = store.user { - Section { - LabeledContent("Account", value: user.displayName) - if let email = user.email { - LabeledContent("Email", value: email) - } - LabeledContent("Tier", value: user.membershipTier) - - Button(role: .destructive) { - store.signOut() - } label: { - Text("Sign Out") - } - } header: { - Label("Account", systemImage: "person.crop.circle") + Picker(selection: $store.settings.preferredFPS) { + ForEach(fpsValues, id: \.self) { Text("\($0) fps").tag($0) } + } label: { + Label("Target FPS", systemImage: "speedometer") + } + + Picker(selection: $store.settings.preferredQuality) { + ForEach(qualityValues, id: \.self) { Text($0).tag($0) } + } label: { + Label("Quality", systemImage: "slider.horizontal.3") + } + + Picker(selection: $store.settings.preferredCodec) { + ForEach(codecValues, id: \.self) { Text($0).tag($0) } + } label: { + Label("Codec", systemImage: "video") + } + + Picker(selection: $store.settings.maxBitrateMbps) { + ForEach(StreamSettingsResolver.bitrateOptionsMbps, id: \.self) { value in + Text(bitrateLabel(for: value)).tag(value) + } + } label: { + Label("Max Bitrate", systemImage: "gauge.with.dots.needle.67percent") + } + } header: { + Text("Streaming") + } footer: { + Text("Streaming changes apply to the next session launch.") + } + } + + private var sessionSection: some View { + Section("Game Session") { + Picker(selection: $store.settings.keyboardLayout) { + ForEach(StreamSettingsResolver.keyboardLayoutOptions, id: \.value) { option in + Text(option.label).tag(option.value) + } + } label: { + Label("Keyboard Layout", systemImage: "keyboard") + } + + Picker(selection: $store.settings.gameLanguage) { + ForEach(StreamSettingsResolver.gameLanguageOptions, id: \.value) { option in + Text(option.label).tag(option.value) + } + } label: { + Label("Game Language", systemImage: "character.book.closed") + } + + Toggle(isOn: $store.settings.enableL4S) { + Label("Low Latency Mode", systemImage: "bolt.horizontal") + } + + } + } + + private var inputSection: some View { + Section { + #if !os(tvOS) + Toggle(isOn: $store.settings.fortnitePrefersNativeTouch) { + Label("Fortnite Mobile Touch", systemImage: "hand.tap") + } + + Toggle(isOn: $store.settings.streamerPreferences.touchControllerVisible) { + Label("Show Touch Controller", systemImage: "circle.grid.cross") + } + + Toggle(isOn: $store.settings.streamerPreferences.touchscreenModeEnabled) { + Label("Touchscreen Mode", systemImage: "hand.draw") + } + #endif + + HStack { + Label("Controller Passthrough", systemImage: "gamecontroller") + Spacer() + Text("Automatic") + .foregroundStyle(.secondary) + } + } header: { + Text("Input") + } footer: { + #if os(tvOS) + Text("Connected controllers are passed through using the native gamepad path. Touch overlays are disabled on Apple TV.") + #else + Text("Touchscreen Mode taps and drags directly at the touched stream location. Bluetooth controllers are detected automatically.") + #endif + } + } + + private var appSection: some View { + Section("Experience") { + Toggle(isOn: $store.settings.keepMicEnabled) { + Label("Keep Microphone On", systemImage: "mic") + } + + Toggle(isOn: $store.settings.showStatsOverlay) { + Label("Stats Overlay", systemImage: "chart.bar") + } + + #if !os(tvOS) + Toggle(isOn: $store.settings.queueLiveActivitiesEnabled) { + Label("Queue Live Activities", systemImage: "livephoto") + } + #endif + + Toggle(isOn: $store.settings.hideServerSelector) { + Label("Skip Server Selector", systemImage: "server.rack") + } + } + } + + private var dataSection: some View { + Section("Data") { + Button { + Task { await store.refreshCatalog() } + } label: { + HStack { + Label("Reload Catalog", systemImage: "arrow.clockwise") + Spacer() + if store.isLoadingGames { + ProgressView() } } + } + .disabled(store.isLoadingGames) - Section { - LabeledContent("Version", value: "1.0") - LabeledContent("Platform", value: OpenNOWPlatform.displayName) - Link(destination: URL(string: "https://github.com/OpenCloudGaming/OpenNOW")!) { - Text("GitHub Repository") + if !store.settings.favoriteGameIds.isEmpty { + Button(role: .destructive) { + store.clearFavorites() + } label: { + HStack { + Label("Clear Favorites", systemImage: "heart.slash") + Spacer() + Text("\(store.settings.favoriteGameIds.count)") + .foregroundStyle(.secondary) } - } header: { - Label("About", systemImage: "info.circle") } } - .navigationTitle("Settings") - .background(appBackground) - .settingsFormBackground() - .onChange(of: store.settings) { _, _ in - store.persistSettings() + } + } + + private func accountSection(_ user: UserProfile) -> some View { + Section("Account") { + LabeledContent("Account", value: user.displayName) + if let email = user.email { + LabeledContent("Email", value: email) + } + LabeledContent("Tier", value: user.membershipTier) + Button(role: .destructive) { + store.signOut() + } label: { + Text("Sign Out") } } } - private func settingRow( - icon: String, - color: Color, - title: String, - picker: Content - ) -> some View { - HStack { - ZStack { - RoundedRectangle(cornerRadius: 7) - .fill(color) - .frame(width: 30, height: 30) - Image(systemName: icon) - .font(.caption.bold()) - .foregroundStyle(.white) - } - picker - .pickerStyle(.menu) - .tint(.primary) + private var aboutSection: some View { + Section("About") { + LabeledContent("Version", value: "1.0") + LabeledContent("Platform", value: OpenNOWPlatform.displayName) + Link(destination: URL(string: "https://github.com/OpenCloudGaming/OpenNOW")!) { + Text("GitHub Repository") + } } - .listRowBackground(settingsRowBackground) } - private var settingsRowBackground: Color { - #if os(tvOS) - return Color.white.opacity(0.08) - #else - return Color(.secondarySystemGroupedBackground).opacity(0.75) - #endif + private var headerSummary: String { + let profile = StreamSettingsResolver.profile(for: store.settings) + return "\(profile.width)x\(profile.height) at \(profile.fps) fps" } -} -private extension View { - @ViewBuilder - func settingsFormBackground() -> some View { - #if os(tvOS) - self - #else - self.scrollContentBackground(.hidden) - #endif + private func bitrateLabel(for value: Int) -> String { + value == 0 ? "Auto" : "\(value) Mbps" } } diff --git a/ios/OpenNOWiOS/OpenNOWiOS/StreamLoadingView.swift b/ios/OpenNOWiOS/OpenNOWiOS/StreamLoadingView.swift index e50ca3e0..641110e3 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/StreamLoadingView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/StreamLoadingView.swift @@ -1,573 +1,751 @@ -import SwiftUI import AVKit +import SwiftUI struct StreamLoadingView: View { - @EnvironmentObject private var store: OpenNOWStore - - private enum StreamPhase: Equatable { - case queue - case setup - case launching + @EnvironmentObject private var store: OpenNOWStore + + private enum StreamPhase: Equatable { + case queue + case setup + case launching + } + + private enum StepState: Equatable { + case pending + case active + case completed + } + + private let steps: [(title: String, icon: String)] = [ + ("Queue", "person.3.fill"), + ("Setup", "cpu"), + ("Launching", "wifi"), + ] + + private var currentPhase: StreamPhase { + if let adState = store.effectiveAdState, + store.activeSession?.status == 1, + adState.sessionAdsRequired ?? adState.isAdsRequired + { + return .queue } - - private enum StepState: Equatable { - case pending - case active - case completed + guard let session = store.activeSession else { return .queue } + switch session.status { + case 2: return .setup + case 3: return .launching + case 1: + if session.seatSetupStep == 1 { + return .queue + } + if let pos = session.queuePosition, pos > 1 { + return .queue + } + return .setup + default: return .queue } - - private let steps: [(title: String, icon: String)] = [ - ("Queue", "person.3.fill"), - ("Setup", "cpu"), - ("Launching", "wifi") - ] - - private var currentPhase: StreamPhase { - if let adState = store.effectiveAdState, - store.activeSession?.status == 1, - adState.sessionAdsRequired ?? adState.isAdsRequired { - return .queue + } + + private var statusMessage: String { + switch currentPhase { + case .queue: + if let adState = store.effectiveAdState, store.activeQueueAd != nil { + if adState.opportunity?.queuePaused == true || adState.isQueuePaused == true { + return adState.message ?? "Session queue paused. Resume ad playback to continue." } - guard let session = store.activeSession else { return .queue } - switch session.status { - case 2: return .setup - case 3: return .launching - case 1: - if session.seatSetupStep == 1 { - return .queue - } - if let pos = session.queuePosition, pos > 1 { - return .queue - } - return .setup - default: return .queue + if adState.sessionAdsRequired ?? adState.isAdsRequired { + return adState.message ?? "Watch queue ads to continue." } + } + if let pos = store.activeSession?.queuePosition { + return pos == 1 ? "A cloud rig is nearly ready." : "Waiting for a cloud rig to free up." + } + return store.isLaunchingSession ? "Starting session..." : "Waiting in queue..." + case .setup: + return "Setting up your gaming rig..." + case .launching: + if store.streamSession != nil { + return "Opening stream..." + } + if let signalingUrl = store.activeSession?.signalingUrl, !signalingUrl.isEmpty { + return "Connecting streamer..." + } + if let signalingServer = store.activeSession?.signalingServer, !signalingServer.isEmpty { + return "Connecting streamer..." + } + return "Finalizing stream endpoint..." } - - private var statusMessage: String { - switch currentPhase { - case .queue: - if let adState = store.effectiveAdState, store.activeQueueAd != nil { - if adState.opportunity?.queuePaused == true || adState.isQueuePaused == true { - return adState.message ?? "Session queue paused. Resume ad playback to continue." - } - if adState.sessionAdsRequired ?? adState.isAdsRequired { - return adState.message ?? "Watch queue ads to continue." - } - } - if let pos = store.activeSession?.queuePosition { - return pos == 1 ? "Almost there! Your session is about to start..." : "Position #\(pos) in queue" - } - return store.isLaunchingSession ? "Starting session..." : "Waiting in queue..." - case .setup: - return "Setting up your gaming rig..." - case .launching: - if store.streamSession != nil { - return "Opening stream..." + } + + var body: some View { + GeometryReader { proxy in + let isWide = shouldUseWideLayout(size: proxy.size) + ZStack { + #if os(tvOS) + Color.black + .ignoresSafeArea() + #else + Color(.systemBackground) + .ignoresSafeArea() + #endif + + if isWide { + landscapeQueueLayout(proxy: proxy) + } else { + let hasAd = store.activeQueueAd != nil + let topContentInset = max(28, proxy.safeAreaInsets.top + (hasAd ? 14 : 18)) + let bottomContentInset = max(28, proxy.safeAreaInsets.bottom + 18) + ScrollView { + Group { + VStack(spacing: hasAd ? 18 : 24) { + gameHeader(isWide: false, compact: hasAd) + queueProgressPanel(isWide: false) + } + .frame(maxWidth: 430) } - if let signalingUrl = store.activeSession?.signalingUrl, !signalingUrl.isEmpty { - return "Connecting streamer..." - } - if let signalingServer = store.activeSession?.signalingServer, !signalingServer.isEmpty { - return "Connecting streamer..." - } - return "Finalizing stream endpoint..." + .padding(.horizontal, 24) + .padding(.top, topContentInset) + .padding(.bottom, bottomContentInset) + .frame(maxWidth: .infinity) + .frame( + minHeight: max( + 0, proxy.size.height - topContentInset - bottomContentInset)) + } + #if os(iOS) + .scrollBounceBehavior(.basedOnSize) + #endif } + } } - - var body: some View { - ZStack { - #if os(tvOS) - Color.black - .ignoresSafeArea() - #else - Color(.systemBackground) - .ignoresSafeArea() - #endif - - VStack(spacing: 24) { - Spacer() - - gameHeader - - if currentPhase == .queue, let pos = store.activeSession?.queuePosition { - HStack { - Image(systemName: "person.3.fill") - .foregroundStyle(.orange) - Text("Queue position: \(pos)") - .font(.subheadline.bold()) - .foregroundStyle(.primary) - } - .padding(12) - .background( - Group { - if #available(iOS 26, *) { - RoundedRectangle(cornerRadius: 14) - .fill(.regularMaterial) - .glassEffect(in: RoundedRectangle(cornerRadius: 14)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(.orange.opacity(0.35), lineWidth: 1) - ) - } else { - RoundedRectangle(cornerRadius: 14) - .fill(.regularMaterial) - .overlay( - RoundedRectangle(cornerRadius: 14) - .fill(.orange.opacity(0.08)) - ) - } - } - ) - .frame(maxWidth: 320) - .numericQueueTransition(value: pos) - .transition(.scale(scale: 0.92).combined(with: .opacity)) - } - - if let ad = store.activeQueueAd { - QueueAdPlayerCard(ad: ad) - .environmentObject(store) - } - - stepsView - - Text(statusMessage) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .numericQueueTransition(value: store.activeSession?.queuePosition ?? -1) - - ProgressView() - .progressViewStyle(.circular) - .tint(brandAccent) - .scaleEffect(1.3) - - HStack(spacing: 12) { - Button { - Haptics.light() - store.minimizeQueueOverlay() - } label: { - Text("Minimize") - .frame(maxWidth: .infinity) - } - .streamActionButtonStyle() - - Button(role: .destructive) { - Haptics.medium() - Task { await store.endSession() } - } label: { - Text("Cancel") - .frame(maxWidth: .infinity) - } - .streamActionButtonStyle(tint: .red.opacity(0.92)) - } - .frame(maxWidth: 320) - .padding(.top, 8) - - Spacer() - } - .padding(.horizontal, 24) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .animation(.spring(response: 0.34, dampingFraction: 0.84), value: currentPhase) + .animation( + .spring(response: 0.34, dampingFraction: 0.84), value: store.activeSession?.queuePosition + ) + .animation(.easeInOut(duration: 0.22), value: statusMessage) + } + + private func shouldUseWideLayout(size: CGSize) -> Bool { + size.width > size.height && size.width >= 680 + } + + private func landscapeQueueLayout(proxy: GeometryProxy) -> some View { + let hasAd = store.activeQueueAd != nil + return HStack(alignment: .center, spacing: hasAd ? 18 : 26) { + VStack(alignment: .leading, spacing: hasAd ? 10 : 12) { + gameHeader(isWide: true, compact: hasAd) + if hasAd { + compactQueueStatus } - .animation(.spring(response: 0.34, dampingFraction: 0.84), value: currentPhase) - .animation(.spring(response: 0.34, dampingFraction: 0.84), value: store.activeSession?.queuePosition) - .animation(.easeInOut(duration: 0.22), value: statusMessage) + } + .frame(maxWidth: hasAd ? 330 : 360, alignment: .leading) + + VStack(spacing: 10) { + if let ad = store.activeQueueAd { + QueueAdPlayerCard(ad: ad, compact: true) + .environmentObject(store) + } else { + queueProgressPanel(isWide: true, includeAd: false, includeActions: false) + } + actionButtons + .frame(maxWidth: 320) + } + .frame(maxWidth: hasAd ? 330 : 380) } - - private var gameHeader: some View { - VStack(spacing: 14) { - BrandLogoView(size: 92) - - VStack(spacing: 4) { - Text(store.activeSession?.game.title ?? "Preparing your game") - .font(.title2.bold()) - .foregroundStyle(.primary) - .multilineTextAlignment(.center) - - Text(store.activeSession?.game.platform ?? "Cloud Gaming") - .font(.caption) - .foregroundStyle(.secondary) - } + .padding(.leading, max(56, proxy.safeAreaInsets.leading + 38)) + .padding(.trailing, max(18, proxy.safeAreaInsets.trailing + 14)) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .clipped() + } + + private func gameHeader(isWide: Bool, compact: Bool = false) -> some View { + VStack(alignment: isWide ? .leading : .center, spacing: compact ? 8 : 14) { + gameArtworkHero(isWide: isWide, compact: compact) + + VStack(alignment: isWide ? .leading : .center, spacing: 4) { + Text(store.activeSession?.game.title ?? "Preparing your game") + .font(compact ? .headline.bold() : isWide ? .title.bold() : .title2.bold()) + .foregroundStyle(.primary) + .multilineTextAlignment(isWide ? .leading : .center) + .lineLimit(compact ? 2 : 3) + .minimumScaleFactor(0.78) + .fixedSize(horizontal: false, vertical: true) + + Text(store.activeSession?.game.platform ?? "Cloud Gaming") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: isWide ? .leading : .center) + } + .frame(maxWidth: .infinity, alignment: isWide ? .leading : .center) + } + + @ViewBuilder + private func gameArtworkHero(isWide: Bool, compact: Bool = false) -> some View { + let cornerRadius: CGFloat = compact ? 18 : isWide ? 24 : 22 + ZStack { + if let game = store.activeSession?.game { + GameArtworkView(game: game, iconSize: compact ? 48 : isWide ? 64 : 58) + } else { + BrandLogoView(size: compact ? 60 : isWide ? 86 : 78) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial) + } + } + .aspectRatio(16.0 / 9.0, contentMode: .fit) + .frame(maxWidth: compact ? 280 : isWide ? 340 : 340) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.18), radius: 18, y: 10) + } + + private func queueProgressPanel(isWide: Bool, includeAd: Bool = true, includeActions: Bool = true) -> some View { + VStack(spacing: isWide ? 18 : 22) { + queuePositionBadge + + if includeAd, let ad = store.activeQueueAd { + QueueAdPlayerCard(ad: ad) + .environmentObject(store) + } + + stepsView + + Text(statusMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .numericQueueTransition(value: store.activeSession?.queuePosition ?? -1) + + ProgressView() + .progressViewStyle(.circular) + .tint(brandAccent) + .scaleEffect(1.3) + + if includeActions { + actionButtons + .frame(maxWidth: 360) + .padding(.top, 4) + } + } + .frame(maxWidth: .infinity) + } + + private var compactQueueStatus: some View { + VStack(alignment: .leading, spacing: 9) { + queuePositionBadge + .frame(maxWidth: 260, alignment: .leading) + + compactStepsView + + Text(statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .minimumScaleFactor(0.82) + .fixedSize(horizontal: false, vertical: true) + .numericQueueTransition(value: store.activeSession?.queuePosition ?? -1) + + ProgressView() + .progressViewStyle(.circular) + .tint(brandAccent) + .scaleEffect(0.82) + .frame(width: 22, height: 22) + } + .padding(.top, 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var actionButtons: some View { + HStack(spacing: 10) { + Button { + Haptics.light() + store.minimizeQueueOverlay() + } label: { + Label("Minimize", systemImage: "rectangle.compress.vertical") + .frame(maxWidth: .infinity) + } + .streamActionButtonStyle() + + Button(role: .destructive) { + Haptics.medium() + Task { await store.endSession() } + } label: { + Label("Cancel", systemImage: "xmark") + .frame(maxWidth: .infinity) + } + .streamActionButtonStyle(tint: .red.opacity(0.92)) + } + } + + @ViewBuilder + private var queuePositionBadge: some View { + if currentPhase == .queue, let pos = store.activeSession?.queuePosition { + HStack(spacing: 10) { + Image(systemName: pos == 1 ? "bolt.fill" : "person.3.fill") + .foregroundStyle(.orange) + Text(pos == 1 ? "Next in queue" : "Queue #\(pos)") + .font(.subheadline.bold()) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.82) + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background( + Group { + if #available(iOS 26, *) { + Capsule() + .fill(.regularMaterial) + .glassEffect(in: Capsule()) + .overlay( + Capsule() + .stroke(.orange.opacity(0.35), lineWidth: 1) + ) + } else { + Capsule() + .fill(.regularMaterial) + .overlay( + Capsule() + .fill(.orange.opacity(0.08)) + ) + } } + ) + .frame(maxWidth: 360) + .numericQueueTransition(value: pos) + .transition(.scale(scale: 0.92).combined(with: .opacity)) } - - private var stepsView: some View { - HStack(spacing: 0) { - ForEach(Array(steps.enumerated()), id: \.offset) { index, step in - VStack(spacing: 10) { - ZStack { - stepCircle(for: stepState(index: index)) - - if stepState(index: index) == .completed { - Image(systemName: "checkmark") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) - } else { - Image(systemName: step.icon) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(iconColor(for: stepState(index: index))) - } - } - .frame(width: 44, height: 44) - - Text(step.title) - .font(.caption.bold()) - .lineLimit(1) - .minimumScaleFactor(0.9) - .foregroundStyle(labelColor(for: stepState(index: index))) - } - .frame(width: 88) - - if index < steps.count - 1 { - Rectangle() - .fill(connectorGradient(after: index)) - .frame(width: 24, height: 2) - .padding(.bottom, 26) - } + } + + private var stepsView: some View { + HStack(spacing: 0) { + ForEach(Array(steps.enumerated()), id: \.offset) { index, step in + VStack(spacing: 10) { + ZStack { + stepCircle(for: stepState(index: index)) + + if stepState(index: index) == .completed { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + } else { + Image(systemName: step.icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(iconColor(for: stepState(index: index))) } + } + .frame(width: 44, height: 44) + + Text(step.title) + .font(.caption.bold()) + .lineLimit(1) + .minimumScaleFactor(0.9) + .foregroundStyle(labelColor(for: stepState(index: index))) } - .frame(maxWidth: 320) - } + .frame(width: 88) - private func stepState(index: Int) -> StepState { - let activeIndex: Int - switch currentPhase { - case .queue: activeIndex = 0 - case .setup: activeIndex = 1 - case .launching: activeIndex = 2 + if index < steps.count - 1 { + Rectangle() + .fill(connectorGradient(after: index)) + .frame(width: 24, height: 2) + .padding(.bottom, 26) } - if index < activeIndex { return .completed } - if index == activeIndex { return .active } - return .pending + } } - - @ViewBuilder - private func stepCircle(for state: StepState) -> some View { - switch state { - case .pending: - Circle() - .fill(Color.secondary.opacity(0.12)) - .overlay( - Circle() - .stroke(Color.secondary.opacity(0.2), lineWidth: 2) - ) - case .active: - TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: false)) { timeline in - let phase = timeline.date.timeIntervalSinceReferenceDate.remainder(dividingBy: 0.9) / 0.9 - let pulse = 0.5 - 0.5 * cos(phase * 2 * .pi) - Group { - if #available(iOS 26, *) { - Circle() - .fill(.regularMaterial) - .glassEffect(in: Circle()) - .overlay( - Circle() - .stroke(brandAccent.opacity(0.55), lineWidth: 1.5) - ) - } else { - Circle() - .fill(brandAccent) - } - } - .scaleEffect(1.0 + (0.15 * pulse)) - .opacity(1.0 - (0.08 * pulse)) + .frame(maxWidth: 320) + } + + private var compactStepsView: some View { + HStack(spacing: 0) { + ForEach(Array(steps.enumerated()), id: \.offset) { index, step in + VStack(spacing: 5) { + ZStack { + stepCircle(for: stepState(index: index)) + + if stepState(index: index) == .completed { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + } else { + Image(systemName: step.icon) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(iconColor(for: stepState(index: index))) } - case .completed: - Circle() - .fill(brandAccent.opacity(0.35)) - .overlay( - Circle() - .stroke(brandAccent, lineWidth: 2) - ) + } + .frame(width: 28, height: 28) + + Text(step.title) + .font(.caption2.bold()) + .lineLimit(1) + .minimumScaleFactor(0.85) + .foregroundStyle(labelColor(for: stepState(index: index))) } - } + .frame(width: 58) - private func iconColor(for state: StepState) -> Color { - switch state { - case .pending: - return .secondary.opacity(0.7) - case .active: - return brandAccent - case .completed: - return .white + if index < steps.count - 1 { + Rectangle() + .fill(connectorGradient(after: index)) + .frame(width: 16, height: 2) + .padding(.bottom, 17) } + } } - - private func labelColor(for state: StepState) -> Color { - switch state { - case .pending: - return .secondary - case .active: - return .primary - case .completed: - return brandAccent - } + .frame(maxWidth: 230, alignment: .leading) + } + + private func stepState(index: Int) -> StepState { + let activeIndex: Int + switch currentPhase { + case .queue: activeIndex = 0 + case .setup: activeIndex = 1 + case .launching: activeIndex = 2 } - - private func connectorGradient(after index: Int) -> LinearGradient { - let startColor: Color = stepState(index: index) == .completed ? brandAccent : .secondary.opacity(0.2) - let endColor: Color - switch stepState(index: index + 1) { - case .pending: - endColor = .secondary.opacity(0.2) - case .active, .completed: - endColor = brandAccent + if index < activeIndex { return .completed } + if index == activeIndex { return .active } + return .pending + } + + @ViewBuilder + private func stepCircle(for state: StepState) -> some View { + switch state { + case .pending: + Circle() + .fill(Color.secondary.opacity(0.12)) + .overlay( + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 2) + ) + case .active: + TimelineView(.animation(minimumInterval: 1.0 / 12.0, paused: false)) { timeline in + let phase = timeline.date.timeIntervalSinceReferenceDate.remainder(dividingBy: 0.9) / 0.9 + let pulse = 0.5 - 0.5 * cos(phase * 2 * .pi) + Group { + if #available(iOS 26, *) { + Circle() + .fill(.regularMaterial) + .glassEffect(in: Circle()) + .overlay( + Circle() + .stroke(brandAccent.opacity(0.55), lineWidth: 1.5) + ) + } else { + Circle() + .fill(brandAccent) + } } - return LinearGradient(colors: [startColor, endColor], startPoint: .leading, endPoint: .trailing) + .scaleEffect(1.0 + (0.15 * pulse)) + .opacity(1.0 - (0.08 * pulse)) + } + case .completed: + Circle() + .fill(brandAccent.opacity(0.35)) + .overlay( + Circle() + .stroke(brandAccent, lineWidth: 2) + ) + } + } + + private func iconColor(for state: StepState) -> Color { + switch state { + case .pending: + return .secondary.opacity(0.7) + case .active: + return brandAccent + case .completed: + return .white + } + } + + private func labelColor(for state: StepState) -> Color { + switch state { + case .pending: + return .secondary + case .active: + return .primary + case .completed: + return brandAccent + } + } + + private func connectorGradient(after index: Int) -> LinearGradient { + let startColor: Color = + stepState(index: index) == .completed ? brandAccent : .secondary.opacity(0.2) + let endColor: Color + switch stepState(index: index + 1) { + case .pending: + endColor = .secondary.opacity(0.2) + case .active, .completed: + endColor = brandAccent } + return LinearGradient( + colors: [startColor, endColor], startPoint: .leading, endPoint: .trailing) + } } // Bare AVPlayerViewController wrapper — no system transport controls so only // our custom play/pause button is visible (no ±10s skip buttons). private struct AdVideoView: UIViewControllerRepresentable { - let player: AVPlayer - - func makeUIViewController(context: Context) -> AVPlayerViewController { - let vc = AVPlayerViewController() - vc.player = player - vc.showsPlaybackControls = false - vc.videoGravity = .resizeAspect - return vc - } - - func updateUIViewController(_ vc: AVPlayerViewController, context: Context) { - vc.player = player - } + let player: AVPlayer + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let vc = AVPlayerViewController() + vc.player = player + vc.showsPlaybackControls = false + vc.videoGravity = .resizeAspect + return vc + } + + func updateUIViewController(_ vc: AVPlayerViewController, context: Context) { + vc.player = player + } } private struct QueueAdPlayerCard: View { - @EnvironmentObject private var store: OpenNOWStore - let ad: SessionAdInfo - - @State private var player = AVPlayer() - @State private var adDurationObserver: Any? - @State private var adEndObserver: NSObjectProtocol? - @State private var currentItemId: String? - @State private var watchedTimeMs = 0 - @State private var didSendFinish = false - @State private var hasReportedPlaying = false - @State private var isPaused = false - @State private var isMuted = false - @State private var isPlaying = false - - var body: some View { - if !didSendFinish { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Image(systemName: "play.rectangle.fill") - .foregroundStyle(.orange) - Text("Ad Queue") - .font(.caption.bold()) - .foregroundStyle(.secondary) - } + @EnvironmentObject private var store: OpenNOWStore + let ad: SessionAdInfo + var compact = false + + @State private var player = AVPlayer() + @State private var adDurationObserver: Any? + @State private var adEndObserver: NSObjectProtocol? + @State private var currentItemId: String? + @State private var watchedTimeMs = 0 + @State private var didSendFinish = false + @State private var hasReportedPlaying = false + @State private var isPaused = false + @State private var isMuted = false + @State private var isPlaying = false + + var body: some View { + if !didSendFinish { + VStack(alignment: .leading, spacing: compact ? 7 : 10) { + HStack(spacing: 8) { + Image(systemName: "play.rectangle.fill") + .foregroundStyle(.orange) + Text("Ad Queue") + .font((compact ? Font.caption2 : Font.caption).bold()) + .foregroundStyle(.secondary) + } - Group { - if let mediaUrl = preferredMediaURLString(for: ad), let url = URL(string: mediaUrl) { - ZStack(alignment: .bottom) { - AdVideoView(player: player) - .frame(height: 150) - .clipShape(RoundedRectangle(cornerRadius: 12)) - - HStack { - Button { - if isPlaying { - player.pause() - } else { - player.play() - } - } label: { - Image(systemName: isPlaying ? "pause.fill" : "play.fill") - .font(.caption.bold()) - .foregroundStyle(.white) - .padding(8) - .background(.ultraThinMaterial, in: Circle()) - } - - Spacer() - - Button { - toggleMute() - } label: { - Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") - .font(.caption.bold()) - .foregroundStyle(.white) - .padding(8) - .background(.ultraThinMaterial, in: Circle()) - } - } - .padding(8) - } - .onAppear { - configurePlayer(url: url) - } - .onChange(of: ad.adId) { _, _ in - didSendFinish = false - hasReportedPlaying = false - isPaused = false - isPlaying = false - configurePlayer(url: url) - } - .onDisappear { - teardownPlayer() - } - } else { - RoundedRectangle(cornerRadius: 12) - .fill(Color.secondary.opacity(0.15)) - .frame(height: 150) - .overlay( - VStack(spacing: 6) { - Image(systemName: "video.slash.fill") - .font(.title3) - .foregroundStyle(.secondary) - Text("Ad media unavailable") - .font(.caption) - .foregroundStyle(.secondary) - } - ) - } + Group { + if let mediaUrl = preferredMediaURLString(for: ad), let url = URL(string: mediaUrl) { + ZStack(alignment: .bottom) { + AdVideoView(player: player) + .frame(height: compact ? 118 : 150) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + HStack { + Button { + if isPlaying { + player.pause() + } else { + player.play() + } + } label: { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(8) + .background(.ultraThinMaterial, in: Circle()) } - if let message = store.effectiveAdState?.message, !message.isEmpty { - Text(message) - .font(.caption) - .foregroundStyle(.secondary) + Spacer() + + Button { + toggleMute() + } label: { + Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(8) + .background(.ultraThinMaterial, in: Circle()) } + } + .padding(8) + } + .onAppear { + configurePlayer(url: url) + } + .onChange(of: ad.adId) { _, _ in + didSendFinish = false + hasReportedPlaying = false + isPaused = false + isPlaying = false + configurePlayer(url: url) } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(.regularMaterial) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(.orange.opacity(0.28), lineWidth: 1) - ) - ) - .frame(maxWidth: 320) + .onDisappear { + teardownPlayer() + } + } else { + RoundedRectangle(cornerRadius: 12) + .fill(Color.secondary.opacity(0.15)) + .frame(height: 150) + .overlay( + VStack(spacing: 6) { + Image(systemName: "video.slash.fill") + .font(.title3) + .foregroundStyle(.secondary) + Text("Ad media unavailable") + .font(.caption) + .foregroundStyle(.secondary) + } + ) + } } - } - private func preferredMediaURLString(for ad: SessionAdInfo) -> String? { - if let firstMedia = ad.adMediaFiles.first(where: { ($0.mediaFileUrl ?? "").isEmpty == false })?.mediaFileUrl { - return firstMedia + if let message = store.effectiveAdState?.message, !message.isEmpty { + Text(message) + .font(compact ? .caption2 : .caption) + .foregroundStyle(.secondary) + .lineLimit(compact ? 2 : nil) + .minimumScaleFactor(0.82) } - if let adUrl = ad.adUrl, !adUrl.isEmpty { - return adUrl - } - if let mediaUrl = ad.mediaUrl, !mediaUrl.isEmpty { - return mediaUrl - } - return nil + } + .padding(compact ? 9 : 12) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(.orange.opacity(0.28), lineWidth: 1) + ) + ) + .frame(maxWidth: compact ? 320 : 320) } + } - private func configurePlayer(url: URL) { - guard currentItemId != ad.adId else { return } - teardownPlayer() - currentItemId = ad.adId - watchedTimeMs = 0 - didSendFinish = false - hasReportedPlaying = false + private func preferredMediaURLString(for ad: SessionAdInfo) -> String? { + if let firstMedia = ad.adMediaFiles.first(where: { ($0.mediaFileUrl ?? "").isEmpty == false })? + .mediaFileUrl + { + return firstMedia + } + if let adUrl = ad.adUrl, !adUrl.isEmpty { + return adUrl + } + if let mediaUrl = ad.mediaUrl, !mediaUrl.isEmpty { + return mediaUrl + } + return nil + } + + private func configurePlayer(url: URL) { + guard currentItemId != ad.adId else { return } + teardownPlayer() + currentItemId = ad.adId + watchedTimeMs = 0 + didSendFinish = false + hasReportedPlaying = false + isPaused = false + isPlaying = false + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + player.isMuted = isMuted + player.volume = 0.3 + player.play() + + adDurationObserver = player.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), + queue: .main + ) { _ in + watchedTimeMs = max(0, Int((player.currentTime().seconds * 1000).rounded())) + let nowPlaying = player.rate > 0.01 + isPlaying = nowPlaying + if nowPlaying, !hasReportedPlaying { + hasReportedPlaying = true isPaused = false - isPlaying = false - - let item = AVPlayerItem(url: url) - player.replaceCurrentItem(with: item) - player.isMuted = isMuted - player.volume = 0.3 - player.play() - - adDurationObserver = player.addPeriodicTimeObserver( - forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), - queue: .main - ) { _ in - watchedTimeMs = max(0, Int((player.currentTime().seconds * 1000).rounded())) - let nowPlaying = player.rate > 0.01 - isPlaying = nowPlaying - if nowPlaying, !hasReportedPlaying { - hasReportedPlaying = true - isPaused = false - store.reportQueueAdStarted(adId: ad.adId) - } else if !nowPlaying, hasReportedPlaying, !didSendFinish, !isPaused { - isPaused = true - store.reportQueueAdPaused(adId: ad.adId) - } else if nowPlaying { - isPaused = false - } + Task { @MainActor in + store.reportQueueAdStarted(adId: ad.adId) } - - adEndObserver = NotificationCenter.default.addObserver( - forName: .AVPlayerItemDidPlayToEndTime, - object: item, - queue: .main - ) { _ in - guard !didSendFinish else { return } - didSendFinish = true - isPlaying = false - store.reportQueueAdFinished(adId: ad.adId, watchedTimeInMs: watchedTimeMs) - Task { @MainActor in - await dismissQueueOverlayIfAdsFinished() - } + } else if !nowPlaying, hasReportedPlaying, !didSendFinish, !isPaused { + isPaused = true + Task { @MainActor in + store.reportQueueAdPaused(adId: ad.adId) } + } else if nowPlaying { + isPaused = false + } } - private func teardownPlayer() { - player.pause() - if let observer = adDurationObserver { - player.removeTimeObserver(observer) - adDurationObserver = nil - } - if let observer = adEndObserver { - NotificationCenter.default.removeObserver(observer) - adEndObserver = nil - } + adEndObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { _ in + guard !didSendFinish else { return } + didSendFinish = true + isPlaying = false + Task { @MainActor in + store.reportQueueAdFinished(adId: ad.adId, watchedTimeInMs: watchedTimeMs) + await dismissQueueOverlayIfAdsFinished() + } } + } - private func toggleMute() { - isMuted.toggle() - player.isMuted = isMuted + private func teardownPlayer() { + player.pause() + if let observer = adDurationObserver { + player.removeTimeObserver(observer) + adDurationObserver = nil } - - @MainActor - private func dismissQueueOverlayIfAdsFinished() async { - for _ in 0..<16 { - let adsRequired = store.effectiveAdState.map { $0.sessionAdsRequired ?? $0.isAdsRequired } ?? false - let isQueueing = (store.activeSession?.status ?? 0) == 1 - if isQueueing && (!adsRequired || store.activeQueueAd == nil) { - store.minimizeQueueOverlay() - return - } - try? await Task.sleep(for: .milliseconds(250)) - } + if let observer = adEndObserver { + NotificationCenter.default.removeObserver(observer) + adEndObserver = nil + } + } + + private func toggleMute() { + isMuted.toggle() + player.isMuted = isMuted + } + + @MainActor + private func dismissQueueOverlayIfAdsFinished() async { + for _ in 0..<16 { + let adsRequired = + store.effectiveAdState.map { $0.sessionAdsRequired ?? $0.isAdsRequired } ?? false + let isQueueing = (store.activeSession?.status ?? 0) == 1 + if isQueueing && (!adsRequired || store.activeQueueAd == nil) { + store.minimizeQueueOverlay() + return + } + try? await Task.sleep(for: .milliseconds(250)) } + } } private struct StreamActionButtonStyleModifier: ViewModifier { - let tint: Color - - func body(content: Content) -> some View { - content - .font(.subheadline.bold()) - .foregroundStyle(.white.opacity(0.84)) - .padding(.horizontal, 18) - .padding(.vertical, 11) - .frame(maxWidth: .infinity) - .background( - Group { - if #available(iOS 26, *) { - Capsule() - .fill(tint.opacity(0.14)) - .glassEffect(in: Capsule()) - } else { - Capsule() - .fill(.regularMaterial) - .overlay(Capsule().fill(tint.opacity(0.14))) - } - } - ) - } + let tint: Color + + func body(content: Content) -> some View { + content + .font(.subheadline.bold()) + .foregroundStyle(.white.opacity(0.84)) + .padding(.horizontal, 18) + .padding(.vertical, 11) + .frame(maxWidth: .infinity) + .background( + Group { + if #available(iOS 26, *) { + Capsule() + .fill(tint.opacity(0.14)) + .glassEffect(in: Capsule()) + } else { + Capsule() + .fill(.regularMaterial) + .overlay(Capsule().fill(tint.opacity(0.14))) + } + } + ) + } } -private extension View { - func streamActionButtonStyle(tint: Color = .white) -> some View { - modifier(StreamActionButtonStyleModifier(tint: tint)) - } +extension View { + fileprivate func streamActionButtonStyle(tint: Color = .white) -> some View { + modifier(StreamActionButtonStyleModifier(tint: tint)) + } } diff --git a/ios/OpenNOWiOS/OpenNOWiOS/StreamerView.swift b/ios/OpenNOWiOS/OpenNOWiOS/StreamerView.swift index 3ba81656..1c5d4d4e 100644 --- a/ios/OpenNOWiOS/OpenNOWiOS/StreamerView.swift +++ b/ios/OpenNOWiOS/OpenNOWiOS/StreamerView.swift @@ -17,133 +17,144 @@ struct StreamerView: View { @State private var statusText = "" @State private var latestStatusLine = "Initializing streamer..." @State private var isPeerConnected = false + @State private var isShowingExitConfirmation = false private var isShowingConnectionOverlay: Bool { !isPeerConnected } var body: some View { - ZStack(alignment: .topTrailing) { - StreamerWebView( - session: session, - settings: settings, - onTouchLayoutChange: onTouchLayoutChange, - onStreamerPreferencesChange: onStreamerPreferencesChange - ) { event in - logger.info("Streamer event: \(event, privacy: .public)") - statusText = event - if event.hasPrefix("Status: ") { - latestStatusLine = String(event.dropFirst("Status: ".count)) - if latestStatusLine.localizedCaseInsensitiveContains("peer: connected") { + GeometryReader { proxy in + ZStack(alignment: .topTrailing) { + StreamerWebView( + session: session, + settings: settings, + onTouchLayoutChange: onTouchLayoutChange, + onStreamerPreferencesChange: onStreamerPreferencesChange + ) { event in + logger.info("Streamer event: \(event, privacy: .public)") + statusText = event + if event.hasPrefix("Status: ") { + latestStatusLine = String(event.dropFirst("Status: ".count)) + if latestStatusLine.localizedCaseInsensitiveContains("peer: connected") { + isPeerConnected = true + } + } + if event.localizedCaseInsensitiveContains("peer: connected") { isPeerConnected = true } + if event.hasPrefix("Error:") { + isPeerConnected = false + } } - if event.localizedCaseInsensitiveContains("peer: connected") { - isPeerConnected = true - } - if event.hasPrefix("Error:") { - isPeerConnected = false - } - } - .ignoresSafeArea() - - if isShowingConnectionOverlay { - ZStack { - Color.black.opacity(0.72) - .ignoresSafeArea() - - VStack(spacing: 14) { - if statusText.hasPrefix("Error:") { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 26, weight: .semibold)) - .foregroundStyle(.orange) - } else { - ProgressView() - .progressViewStyle(.circular) - .scaleEffect(1.25) - .tint(.white) - } - - Text(statusText.hasPrefix("Error:") ? "Connection issue" : "Connecting to stream...") - .font(.headline.weight(.semibold)) - .foregroundStyle(.white) - - Text(statusText.hasPrefix("Error:") ? statusText.replacingOccurrences(of: "Error: ", with: "") : latestStatusLine) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.85)) - .multilineTextAlignment(.center) - .lineLimit(3) - .padding(.horizontal, 12) + .ignoresSafeArea() + + if isShowingConnectionOverlay { + ZStack { + Color.black.opacity(0.72) + .ignoresSafeArea() + + VStack(spacing: 14) { + if statusText.hasPrefix("Error:") { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(.orange) + } else { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(1.25) + .tint(.white) + } - if statusText.hasPrefix("Error:"), let retry = onRetry { - Button("Retry") { - retry() + Text(statusText.hasPrefix("Error:") ? "Connection issue" : "Connecting to stream...") + .font(.headline.weight(.semibold)) + .foregroundStyle(.white) + + Text(statusText.hasPrefix("Error:") ? statusText.replacingOccurrences(of: "Error: ", with: "") : latestStatusLine) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + .multilineTextAlignment(.center) + .lineLimit(3) + .padding(.horizontal, 12) + + if statusText.hasPrefix("Error:"), let retry = onRetry { + Button("Retry") { + retry() + } + .buttonStyle(.borderedProminent) + .tint(.white.opacity(0.22)) + .foregroundStyle(.white) + .padding(.top, 4) } - .buttonStyle(.borderedProminent) - .tint(.white.opacity(0.22)) - .foregroundStyle(.white) - .padding(.top, 4) } + .padding(.horizontal, 20) + .padding(.vertical, 18) + .frame(maxWidth: 340) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .padding(.horizontal, 20) } - .padding(.horizontal, 20) - .padding(.vertical, 18) - .frame(maxWidth: 340) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .padding(.horizontal, 20) + .transition(.opacity) } - .transition(.opacity) - } - Button { - onClose() - } label: { - Image(systemName: "xmark") - .font(.headline.weight(.bold)) - .foregroundStyle(.white.opacity(0.9)) - .frame(width: 36, height: 36) - .background( - Group { - if #available(iOS 26, *) { - Circle() - .fill(.regularMaterial) - .glassEffect(in: Circle()) - } else { - Circle() - .fill(.regularMaterial) - .overlay( - Circle() - .stroke(Color.white.opacity(0.22), lineWidth: 1) - ) + Button { + isShowingExitConfirmation = true + } label: { + Image(systemName: "xmark") + .font(.headline.weight(.bold)) + .foregroundStyle(.white.opacity(0.9)) + .frame(width: 36, height: 36) + .background( + Group { + if #available(iOS 26, *) { + Circle() + .fill(.regularMaterial) + .glassEffect(in: Circle()) + } else { + Circle() + .fill(.regularMaterial) + .overlay( + Circle() + .stroke(Color.white.opacity(0.22), lineWidth: 1) + ) + } } - } - ) - } - .padding(.top, 12) - .padding(.trailing, 12) - - if statusText.hasPrefix("Error:") { - VStack { - Spacer() - Text(statusText) - .font(.caption.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.red.opacity(0.2), in: Capsule()) - .overlay( - Capsule() - .stroke(Color.red.opacity(0.45), lineWidth: 1) ) - .foregroundStyle(.white) - .padding(.bottom, 22) + } + .padding(.top, topControlPadding(in: proxy)) + .padding(.trailing, trailingControlPadding(in: proxy)) + .accessibilityLabel("Exit stream") + + if statusText.hasPrefix("Error:") { + VStack { + Spacer() + Text(statusText) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.red.opacity(0.2), in: Capsule()) + .overlay( + Capsule() + .stroke(Color.red.opacity(0.45), lineWidth: 1) + ) + .foregroundStyle(.white) + .padding(.bottom, 22) + } + } + + if isShowingExitConfirmation { + exitConfirmationOverlay + .ignoresSafeArea() + .transition(.opacity) } } + .animation(.easeInOut(duration: 0.18), value: isShowingExitConfirmation) } .background(Color.black.ignoresSafeArea()) .onAppear { @@ -164,6 +175,101 @@ struct StreamerView: View { ) } } + + private var exitConfirmationOverlay: some View { + ZStack { + Color.black.opacity(0.5) + .onTapGesture { + isShowingExitConfirmation = false + } + + VStack(alignment: .leading, spacing: 12) { + Text("Session Control") + .font(.caption.weight(.bold)) + .textCase(.uppercase) + .foregroundStyle(.white.opacity(0.55)) + + Text("Exit Stream?") + .font(.title3.weight(.bold)) + .foregroundStyle(.white) + + Text("Do you really want to exit \(session.game.title)?") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.86)) + + Text("Your current cloud gaming session will be closed.") + .font(.caption) + .foregroundStyle(.white.opacity(0.62)) + + HStack(spacing: 10) { + Button { + isShowingExitConfirmation = false + } label: { + Text("Keep Playing") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(StreamExitButtonStyle(kind: .cancel)) + + Button(role: .destructive) { + isShowingExitConfirmation = false + onClose() + } label: { + Text("Exit Stream") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(StreamExitButtonStyle(kind: .confirm)) + } + .padding(.top, 6) + } + .padding(18) + .frame(maxWidth: 340) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.34), radius: 28, x: 0, y: 18) + ) + .padding(.horizontal, 24) + } + } + + private func topControlPadding(in proxy: GeometryProxy) -> CGFloat { + max(proxy.safeAreaInsets.top + 10, 28) + } + + private func trailingControlPadding(in proxy: GeometryProxy) -> CGFloat { + max(proxy.safeAreaInsets.trailing + 12, 12) + } +} + +private struct StreamExitButtonStyle: ButtonStyle { + enum Kind { + case cancel + case confirm + } + + let kind: Kind + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(kind == .confirm ? Color.white : Color.white.opacity(0.92)) + .padding(.vertical, 11) + .background( + Capsule() + .fill(kind == .confirm ? Color.red.opacity(0.88) : Color.white.opacity(0.14)) + .overlay( + Capsule() + .stroke(Color.white.opacity(kind == .confirm ? 0.12 : 0.18), lineWidth: 1) + ) + ) + .opacity(configuration.isPressed ? 0.72 : 1) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + } } private struct StreamerWebView: UIViewRepresentable { @@ -176,12 +282,6 @@ private struct StreamerWebView: UIViewRepresentable { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - private struct StreamProfile { - let width: Int - let height: Int - let maxBitrateKbps: Int - } - func makeCoordinator() -> Coordinator { Coordinator( onEvent: onEvent, @@ -217,7 +317,6 @@ private struct StreamerWebView: UIViewRepresentable { uiView.configuration.userContentController.removeScriptMessageHandler(forName: "opennow") coordinator.detach() } - } private func buildHTML(for session: ActiveSession, settings: AppSettings) -> String { struct Bridge: Encodable { @@ -238,7 +337,8 @@ private struct StreamerWebView: UIViewRepresentable { let touchProfile: String let touchLayout: TouchControlLayout let streamerPreferences: StreamerPreferences - let allowNativeTouchPassthrough: Bool + let prefersTouchControllerOverlay: Bool + let isTVOS: Bool } let signalingServer = session.signalingServer ?? session.serverIp ?? URL(string: session.streamingBaseUrl)?.host ?? "" @@ -246,6 +346,20 @@ private struct StreamerWebView: UIViewRepresentable { let serverIp = session.serverIp ?? signalingServer let profile = Self.streamProfile(for: settings) let touchProfile = Self.touchProfile(for: session.game.title) + #if os(tvOS) + let isTVOS = true + let streamerPreferences = StreamerPreferences( + audioMuted: false, + showStatsClock: settings.streamerPreferences.showStatsClock, + showStatsBattery: false, + touchControllerVisible: false, + touchscreenModeEnabled: false, + physicalControllerPassthrough: true + ) + #else + let isTVOS = false + let streamerPreferences = settings.streamerPreferences + #endif let bridge = Bridge( sessionId: session.id, signalingServer: signalingServer, @@ -255,7 +369,7 @@ private struct StreamerWebView: UIViewRepresentable { mediaIp: session.mediaIp, mediaPort: session.mediaPort, preferredCodec: Self.normalizePreferredCodec(settings.preferredCodec), - fps: min(settings.preferredFPS, 60), + fps: profile.fps, maxBitrateKbps: profile.maxBitrateKbps, width: profile.width, height: profile.height, @@ -263,8 +377,9 @@ private struct StreamerWebView: UIViewRepresentable { gameTitle: session.game.title, touchProfile: touchProfile, touchLayout: settings.touchLayout(for: touchProfile), - streamerPreferences: settings.streamerPreferences, - allowNativeTouchPassthrough: touchProfile == "fortnite-mobile" && settings.fortnitePrefersNativeTouch + streamerPreferences: streamerPreferences, + prefersTouchControllerOverlay: !isTVOS && touchProfile == "fortnite-mobile" && settings.fortnitePrefersNativeTouch, + isTVOS: isTVOS ) let data = (try? JSONEncoder().encode(bridge)) ?? Data("{}".utf8) let payload = String(data: data, encoding: .utf8) ?? "{}" @@ -272,53 +387,61 @@ private struct StreamerWebView: UIViewRepresentable { - +