diff --git a/Apps/Stratix/Sources/Stratix/Consoles/ConsoleCardView.swift b/Apps/Stratix/Sources/Stratix/Consoles/ConsoleCardView.swift index a720c86..d724c6f 100644 --- a/Apps/Stratix/Sources/Stratix/Consoles/ConsoleCardView.swift +++ b/Apps/Stratix/Sources/Stratix/Consoles/ConsoleCardView.swift @@ -62,7 +62,7 @@ struct ConsoleCardView: View { Spacer(minLength: 10) Image(systemName: "chevron.right") .font(.system(size: 18, weight: .bold)) - .foregroundStyle(Color.white.opacity(isFocused ? 0.95 : 0.45)) + .foregroundStyle(Color.white.opacity(isFocused ? 0.95 : 0.60)) } .padding(.horizontal, 16) .frame(maxWidth: .infinity, minHeight: 58, alignment: .leading) @@ -156,7 +156,7 @@ struct ConsoleCardView: View { Text(text) .lineLimit(1) } - .font(.system(size: 13, weight: .bold, design: .rounded)) + .font(.system(size: 16, weight: .bold, design: .rounded)) .foregroundStyle(foreground) .padding(.horizontal, 10) .padding(.vertical, 6) diff --git a/Apps/Stratix/Sources/Stratix/Consoles/ConsoleListFocusCoordinator.swift b/Apps/Stratix/Sources/Stratix/Consoles/ConsoleListFocusCoordinator.swift index f91986e..7a8e45b 100644 --- a/Apps/Stratix/Sources/Stratix/Consoles/ConsoleListFocusCoordinator.swift +++ b/Apps/Stratix/Sources/Stratix/Consoles/ConsoleListFocusCoordinator.swift @@ -72,8 +72,30 @@ extension ConsoleListView { requestPrimaryFocus() } + /// Applies a pending shell focus hand-off exactly once per generation. + func consumeFocusHandoffIfNeeded() { + guard let request = focusHandoffRequest, + request.route == .consoles, + request.generation != consumedFocusHandoffGeneration else { return } + consumedFocusHandoffGeneration = request.generation + requestPrimaryFocus() + } + /// Schedules the best available focus target once the console route is ready to accept focus. + /// Leaves focus alone when the user is already on a still-valid target so background + /// refreshes never yank focus out from under the remote. func requestPrimaryFocus() { + if let focusedTarget { + switch focusedTarget { + case .console(let consoleID) where consoleIDs.contains(consoleID): + return + case .refresh, .troubleshoot: + return + default: + break + } + } + guard let preferredTarget = ConsoleListFocusCoordinator.preferredTarget( isLoading: consoleController.isLoading, consoleIDs: consoleIDs, diff --git a/Apps/Stratix/Sources/Stratix/Consoles/ConsoleListView.swift b/Apps/Stratix/Sources/Stratix/Consoles/ConsoleListView.swift index 04b2def..7067bf4 100644 --- a/Apps/Stratix/Sources/Stratix/Consoles/ConsoleListView.swift +++ b/Apps/Stratix/Sources/Stratix/Consoles/ConsoleListView.swift @@ -11,6 +11,8 @@ struct ConsoleListView: View { @Environment(ConsoleController.self) var consoleController @Environment(StreamController.self) var streamController var onRequestSideRailEntry: () -> Void = {} + var focusHandoffRequest: CloudLibraryFocusState.ContentFocusRequest? = nil + @State var consumedFocusHandoffGeneration: Int? @State var showingStream = false @State var selectedConsole: RemoteConsole? @State var showTroubleshootDetails = false @@ -30,9 +32,11 @@ struct ConsoleListView: View { } init( - onRequestSideRailEntry: @escaping () -> Void = {} + onRequestSideRailEntry: @escaping () -> Void = {}, + focusHandoffRequest: CloudLibraryFocusState.ContentFocusRequest? = nil ) { self.onRequestSideRailEntry = onRequestSideRailEntry + self.focusHandoffRequest = focusHandoffRequest } /// Mounts the console shell and presents the stream surface when a console launch is active. @@ -43,9 +47,14 @@ struct ConsoleListView: View { .accessibilityHidden(shellVisibility.isAccessibilityHidden) .fullScreenCover(isPresented: $showingStream, onDismiss: handleStreamDismissed) { if let console = selectedConsole { - StreamControllerInputHost(onOverlayToggle: { - streamController.requestOverlayToggle() - }) { + StreamControllerInputHost( + onOverlayToggle: { + streamController.requestOverlayToggle() + }, + onMenuPress: { + streamController.requestOverlayToggle() + } + ) { StreamView(context: .home(console: console)) } .ignoresSafeArea() @@ -109,6 +118,9 @@ struct ConsoleListView: View { .onChange(of: consoleIDs) { _, consoleIDs in handleConsoleIDsChange(consoleIDs) } + .onChange(of: focusHandoffRequest) { _, _ in + consumeFocusHandoffIfNeeded() + } .onMoveCommand { direction in NavigationPerformanceTracker.recordRemoteMoveStart(surface: "consoles", direction: direction) } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Consoles/CloudLibraryConsolesView.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Consoles/CloudLibraryConsolesView.swift index bb57a65..2cc760c 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Consoles/CloudLibraryConsolesView.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Consoles/CloudLibraryConsolesView.swift @@ -7,9 +7,13 @@ import StratixCore struct CloudLibraryConsolesView: View { var onRequestSideRailEntry: () -> Void = {} + var focusHandoffRequest: CloudLibraryFocusState.ContentFocusRequest? = nil var body: some View { - ConsoleListView(onRequestSideRailEntry: onRequestSideRailEntry) + ConsoleListView( + onRequestSideRailEntry: onRequestSideRailEntry, + focusHandoffRequest: focusHandoffRequest + ) } } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailGallery.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailGallery.swift index 8eee853..55653da 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailGallery.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailGallery.swift @@ -42,10 +42,6 @@ extension CloudLibraryTitleDetailScreen { } ) .focused($focusedGalleryIndex, equals: index) - .onMoveCommand { direction in - guard direction == .down else { return } - requestDetailPanelFocus() - } } } .padding(.vertical, 4) diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailHeroHeader.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailHeroHeader.swift index 186553b..46b60cf 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailHeroHeader.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailHeroHeader.swift @@ -68,9 +68,9 @@ extension CloudLibraryTitleDetailScreen { if let contextLabel = state.contextLabel, !contextLabel.isEmpty { HStack(spacing: 8) { Image(systemName: "location.fill") - .font(.system(size: 12, weight: .bold)) + .font(.system(size: 14, weight: .bold)) Text(contextLabel) - .font(.system(size: 14, weight: .bold, design: .rounded)) + .font(.system(size: 17, weight: .bold, design: .rounded)) .lineLimit(1) } .foregroundStyle(StratixTheme.Colors.textMuted) @@ -85,7 +85,7 @@ extension CloudLibraryTitleDetailScreen { if let subtitle = state.subtitle, !subtitle.isEmpty { Text(subtitle) - .font(.system(size: 19, weight: .semibold, design: .rounded)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) .foregroundStyle(StratixTheme.Colors.textSecondary) .lineLimit(2) .frame(maxWidth: textMaxWidth, alignment: .leading) @@ -93,9 +93,9 @@ extension CloudLibraryTitleDetailScreen { if let descriptionText = state.descriptionText, !descriptionText.isEmpty { Text(descriptionText) - .font(.system(size: 17, weight: .medium, design: .rounded)) + .font(.system(size: 20, weight: .medium, design: .rounded)) .foregroundStyle(StratixTheme.Colors.textSecondary) - .lineLimit(3) + .lineLimit(2) .frame(maxWidth: textMaxWidth, alignment: .leading) } @@ -107,9 +107,9 @@ extension CloudLibraryTitleDetailScreen { if let gallerySummaryText { HStack(spacing: 10) { Image(systemName: "photo.on.rectangle.angled") - .font(.system(size: 13, weight: .bold)) + .font(.system(size: 15, weight: .bold)) Text(gallerySummaryText) - .font(.system(size: 14, weight: .semibold, design: .rounded)) + .font(.system(size: 17, weight: .semibold, design: .rounded)) .lineLimit(1) } .foregroundStyle(StratixTheme.Colors.textMuted) @@ -118,9 +118,9 @@ extension CloudLibraryTitleDetailScreen { if let achievementSummaryText { HStack(spacing: 10) { Image(systemName: "rosette") - .font(.system(size: 13, weight: .bold)) + .font(.system(size: 15, weight: .bold)) Text(achievementSummaryText) - .font(.system(size: 14, weight: .semibold, design: .rounded)) + .font(.system(size: 17, weight: .semibold, design: .rounded)) .lineLimit(1) } .foregroundStyle(StratixTheme.Colors.textMuted) @@ -241,7 +241,7 @@ extension CloudLibraryTitleDetailScreen { GlassCard(cornerRadius: 20, fill: Color.black.opacity(0.34), stroke: Color.white.opacity(0.12), shadowOpacity: 0.15) { VStack(alignment: .leading, spacing: 12) { Text("Rating & info") - .font(.system(size: 16, weight: .bold, design: .rounded)) + .font(.system(size: 18, weight: .bold, design: .rounded)) .foregroundStyle(StratixTheme.Colors.textPrimary) if let rating = state.ratingText, !rating.isEmpty { @@ -252,7 +252,7 @@ extension CloudLibraryTitleDetailScreen { if let legal = state.legalText, !legal.isEmpty { Text(legal) - .font(.system(size: 14, weight: .medium, design: .rounded)) + .font(.system(size: 16, weight: .medium, design: .rounded)) .foregroundStyle(StratixTheme.Colors.textSecondary) .fixedSize(horizontal: false, vertical: true) } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailPanels.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailPanels.swift index f047144..8fc03af 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailPanels.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailPanels.swift @@ -18,10 +18,6 @@ extension CloudLibraryTitleDetailScreen { ForEach(state.detailPanels) { panel in DetailPanelCardView(panel: panel) .focused($focusedDetailPanelID, equals: panel.id) - .onMoveCommand { direction in - guard direction == .up else { return } - requestGalleryFocus() - } } } .focusSection() @@ -37,11 +33,11 @@ private struct DetailPanelCardView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { Text(panel.title) - .font(.system(size: 18, weight: .bold, design: .rounded)) + .font(.system(size: 21, weight: .bold, design: .rounded)) .foregroundStyle(StratixTheme.Colors.textPrimary) Text(panel.body) - .font(.system(size: 15, weight: .medium, design: .rounded)) + .font(.system(size: 18, weight: .medium, design: .rounded)) .foregroundStyle(StratixTheme.Colors.textSecondary) .fixedSize(horizontal: false, vertical: true) } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailScreen.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailScreen.swift index 9ec865e..0d2cd18 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailScreen.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Detail/CloudLibraryTitleDetailScreen.swift @@ -23,13 +23,10 @@ struct CloudLibraryTitleDetailScreen: View, Equatable { @Environment(\.dismiss) var dismiss @State var galleryPresentation: GalleryPresentation? - @State var lastFocusedGalleryIndex: Int? - @State var lastFocusedDetailPanelID: String? @State var readiness = CloudLibraryTitleDetailReadinessState() @State var readinessTimeoutTask: Task? @FocusState var focusedGalleryIndex: Int? @FocusState var focusedDetailPanelID: String? - @State var pendingFocusTask: Task? let heroHeight = StratixTheme.Detail.heroHeight let heroPosterWidth = StratixTheme.Detail.heroPosterWidth @@ -44,14 +41,13 @@ struct CloudLibraryTitleDetailScreen: View, Equatable { } var body: some View { - Group { - if interceptExitCommand { - detailBase - .onExitCommand(perform: goBack) - } else { - detailBase - } - } + detailBase + .onExitCommand(perform: exitCommandAction) + } + + private var exitCommandAction: (() -> Void)? { + guard interceptExitCommand else { return nil } + return { goBack() } } private var detailBase: some View { @@ -81,16 +77,6 @@ struct CloudLibraryTitleDetailScreen: View, Equatable { readinessTimeoutTask?.cancel() readinessTimeoutTask = nil } - .onChange(of: focusedGalleryIndex) { _, newValue in - if let index = newValue { - lastFocusedGalleryIndex = index - } - } - .onChange(of: focusedDetailPanelID) { _, newValue in - if let panelID = newValue { - lastFocusedDetailPanelID = panelID - } - } } private var contentScroll: some View { @@ -115,40 +101,6 @@ struct CloudLibraryTitleDetailScreen: View, Equatable { .scrollIndicators(.hidden) } - func requestGalleryFocus() { - guard !state.gallery.isEmpty else { return } - - let targetIndex = state.gallery.indices.contains(lastFocusedGalleryIndex ?? -1) - ? lastFocusedGalleryIndex ?? 0 - : 0 - scheduleFocusTask { - focusedGalleryIndex = targetIndex - } - } - - func requestDetailPanelFocus() { - guard !state.detailPanels.isEmpty else { return } - - let rememberedID = lastFocusedDetailPanelID - let targetID = state.detailPanels.contains(where: { $0.id == rememberedID }) - ? rememberedID - : state.detailPanels.first?.id - guard let targetID else { return } - - scheduleFocusTask { - focusedDetailPanelID = targetID - } - } - - private func scheduleFocusTask(_ updateFocus: @escaping @MainActor () -> Void) { - pendingFocusTask?.cancel() - pendingFocusTask = Task { @MainActor in - await Task.yield() - guard !Task.isCancelled else { return } - updateFocus() - } - } - private func goBack() { if let onBack { onBack() diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeFocusCoordinator.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeFocusCoordinator.swift index 92c9b58..ae7f05e 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeFocusCoordinator.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeFocusCoordinator.swift @@ -38,6 +38,15 @@ extension CloudLibraryHomeScreen { case showAllCard(String) } + var isHeroFocused: Bool { + focusedTarget == .carouselPlay || focusedTarget == .carouselDetails + } + + /// Restarts the auto-advance timer whenever the page changes or hero focus toggles. + var carouselAutoAdvanceTaskID: String { + "\(carouselIndex)-\(isHeroFocused)" + } + var currentCarouselItem: CloudLibraryHomeCarouselItemViewState? { guard !state.carouselItems.isEmpty else { return nil } let clamped = max(0, min(carouselIndex, state.carouselItems.count - 1)) @@ -85,6 +94,16 @@ extension CloudLibraryHomeScreen { } } + /// Applies a pending shell focus hand-off exactly once per generation so a stale + /// request never yanks focus after a detail pop or unrelated re-render. + func consumeFocusHandoffIfNeeded() { + guard let request = focusHandoffRequest, + request.route == .home, + request.generation != consumedFocusHandoffGeneration else { return } + consumedFocusHandoffGeneration = request.generation + requestFocusFromSideRail() + } + func requestFocusFromSideRail() { if let preferredTitleID, let rememberedTitleID = self.preferredTitleID(for: preferredTitleID), @@ -179,6 +198,9 @@ extension CloudLibraryHomeScreen { carouselIndex = clamped } + /// Handles only the moves the focus engine cannot resolve itself (carousel paging and + /// side-rail entry at the leading edge). Right/down moves belong to the engine — issuing + /// a second, imperative focus change for them made focus visibly jump per press. func handlePlayButtonMove(_ direction: MoveCommandDirection) { switch direction { case .left: @@ -187,10 +209,6 @@ extension CloudLibraryHomeScreen { } else { onRequestSideRailEntry() } - case .right: - focusedTarget = .carouselDetails - case .down: - _ = requestFirstRailFocus() default: break } @@ -198,12 +216,8 @@ extension CloudLibraryHomeScreen { func handleDetailsButtonMove(_ direction: MoveCommandDirection) { switch direction { - case .left: - focusedTarget = .carouselPlay case .right: moveCarousel(by: 1) - case .down: - _ = requestFirstRailFocus() default: break } @@ -212,13 +226,8 @@ extension CloudLibraryHomeScreen { func handleRailMove(sectionIndex: Int, itemIndex: Int, direction: MoveCommandDirection) { recordMediaTileMoveDirection(direction) - guard !(direction == .left && itemIndex == 0) else { - onRequestSideRailEntry() - return - } - - guard direction == .up, sectionIndex == 0 else { return } - focusedTarget = .carouselPlay + guard direction == .left, itemIndex == 0 else { return } + onRequestSideRailEntry() } func logHomeScreenDebug(_ message: @autoclosure () -> String) { diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeHeroComponents.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeHeroComponents.swift index 065f22b..207d5b9 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeHeroComponents.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeHeroComponents.swift @@ -176,7 +176,7 @@ struct CarouselCTAButton: View { ) ) .scaleEffect(isFocused ? 1.05 : 1.0) - .animation(.easeInOut(duration: 0.13), value: isFocused) + .animation(StratixTheme.Motion.focus, value: isFocused) .gamePassFocusRing(isFocused: isFocused, cornerRadius: 30) .zIndex(isFocused ? 10 : 0) } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeRailComponents.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeRailComponents.swift index 29e916d..54c114a 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeRailComponents.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeRailComponents.swift @@ -26,17 +26,17 @@ struct HomeShowAllCardButton: View { .font(StratixTypography.rounded(26, weight: .bold, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(.white) Text(card.label) - .font(StratixTypography.rounded(16, weight: .semibold, dynamicTypeSize: dynamicTypeSize)) - .foregroundStyle(Color.white.opacity(0.72)) + .font(StratixTypography.rounded(19, weight: .semibold, dynamicTypeSize: dynamicTypeSize)) + .foregroundStyle(Color.white.opacity(0.80)) Text("\(card.totalCount) games") - .font(StratixTypography.rounded(14, weight: .medium, dynamicTypeSize: dynamicTypeSize)) - .foregroundStyle(Color.white.opacity(0.60)) + .font(StratixTypography.rounded(17, weight: .medium, dynamicTypeSize: dynamicTypeSize)) + .foregroundStyle(Color.white.opacity(0.65)) } .padding(22) } .frame(width: StratixTheme.Home.railTileWidth, height: StratixTheme.Home.railTileHeight) .scaleEffect(isFocused ? 1.04 : 1.0) - .animation(.easeInOut(duration: 0.13), value: isFocused) + .animation(StratixTheme.Motion.focus, value: isFocused) .gamePassFocusRing(isFocused: isFocused, cornerRadius: StratixTheme.Radius.md) } } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeRailSection.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeRailSection.swift index c58b58b..c10d450 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeRailSection.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeRailSection.swift @@ -20,10 +20,6 @@ extension CloudLibraryHomeScreen { MediaTileView( state: titleItem.tile, onSelect: { onSelectRailItem(item) }, - forcedFocus: focusedTarget == .titleTile( - titleItem.tile.titleID, - sectionID: section.id - ), presentation: .artworkOnly, artworkOverrideSize: CGSize( width: StratixTheme.Home.railTileWidth, diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeScreen.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeScreen.swift index e259378..a220e3b 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeScreen.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Home/CloudLibraryHomeScreen.swift @@ -15,6 +15,7 @@ struct CloudLibraryHomeScreen: View, Equatable { var onFocusTileID: (TitleID?) -> Void = { _ in } var onSettledTileID: (TitleID?) -> Void = { _ in } var tileLookup: [TitleID: TileLookupEntry] = [:] + var focusHandoffRequest: CloudLibraryFocusState.ContentFocusRequest? = nil @Environment(\.dynamicTypeSize) var dynamicTypeSize @FocusState var focusedTarget: HomeFocusTarget? @@ -23,6 +24,7 @@ struct CloudLibraryHomeScreen: View, Equatable { @State var carouselIndex = 0 @State private var scrollAnchorID: String? @State var pendingFocusTask: Task? + @State var consumedFocusHandoffGeneration: Int? let tileFocusScale: CGFloat = StratixTheme.Home.tileFocusScale let tileFocusBreathing: CGFloat = StratixTheme.Home.tileFocusBreathing @@ -33,7 +35,8 @@ struct CloudLibraryHomeScreen: View, Equatable { nonisolated static func == (lhs: CloudLibraryHomeScreen, rhs: CloudLibraryHomeScreen) -> Bool { lhs.state == rhs.state && lhs.preferredTitleID == rhs.preferredTitleID && - lhs.tileLookup == rhs.tileLookup + lhs.tileLookup == rhs.tileLookup && + lhs.focusHandoffRequest == rhs.focusHandoffRequest } var body: some View { @@ -56,7 +59,7 @@ struct CloudLibraryHomeScreen: View, Equatable { primaryActionTitle: nil ) ) - .frame(height: 600) + .frame(minHeight: 600) } else { ForEach(Array(state.sections.enumerated()), id: \.element.id) { sectionIndex, section in rail(section: section, sectionIndex: sectionIndex) @@ -72,15 +75,21 @@ struct CloudLibraryHomeScreen: View, Equatable { .scrollPosition(id: $scrollAnchorID) .scrollIndicators(.hidden) .gamePassDisableSystemFocusEffect() - .task(id: carouselIndex) { - guard state.carouselItems.count > 1 else { return } - try? await Task.sleep(for: .seconds(5)) + .task(id: carouselAutoAdvanceTaskID) { + // Auto-advance pauses while the hero buttons are focused so the content the + // user is about to select never swaps out from under them. + guard state.carouselItems.count > 1, !isHeroFocused else { return } + try? await Task.sleep(for: .seconds(8)) guard !Task.isCancelled else { return } moveCarousel(by: 1) } .onAppear { logHomeScreenDebug("appear \(stateSummary())") syncCarouselIndexIfNeeded() + consumeFocusHandoffIfNeeded() + } + .onChange(of: focusHandoffRequest) { _, _ in + consumeFocusHandoffIfNeeded() } .onDisappear { focusSettler.cancel() diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreen.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreen.swift index 1074659..328e1da 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreen.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreen.swift @@ -18,6 +18,7 @@ struct CloudLibraryLibraryScreen: View, Equatable { var onSelectSort: () -> Void = {} var onClearFilters: () -> Void = {} var onRequestSideRailEntry: () -> Void = {} + var focusHandoffRequest: CloudLibraryFocusState.ContentFocusRequest? = nil @Environment(\.dynamicTypeSize) var dynamicTypeSize @Namespace var gridFocusNamespace @@ -30,11 +31,11 @@ struct CloudLibraryLibraryScreen: View, Equatable { @FocusState var focusedTarget: LibraryFocusTarget? @State var lastFocusedGridTitleID: TitleID? - @State var lastFocusedHeaderTarget: LibraryFocusTarget? @State var cachedGridColumnCount: Int = Self.defaultGridColumnCount @State var cachedColumns: [GridItem] = Self.defaultColumns @State var focusSettler = FocusSettleDebouncer() @State var pendingFocusTask: Task? + @State var consumedFocusHandoffGeneration: Int? let gridItemWidth = StratixTheme.Library.gridItemWidth let gridItemSpacing = StratixTheme.Library.gridItemSpacing @@ -64,7 +65,7 @@ struct CloudLibraryLibraryScreen: View, Equatable { primaryActionTitle: nil ) ) - .frame(height: 480) + .frame(minHeight: 600) } else { LazyVGrid(columns: cachedColumns, alignment: .leading, spacing: StratixTheme.Library.gridItemSpacing) { ForEach(Array(state.gridItems.enumerated()), id: \.element.id) { index, item in @@ -72,18 +73,17 @@ struct CloudLibraryLibraryScreen: View, Equatable { state: item, onSelect: { onSelectTile(item) - }, - forcedFocus: focusedTarget == .tile(item.titleID) + } ) .focused($focusedTarget, equals: .tile(item.titleID)) .prefersDefaultFocus(item.id == defaultGridFocusTileID, in: gridFocusNamespace) .onMoveCommand { direction in NavigationPerformanceTracker.recordRemoteMoveStart(surface: "library", direction: direction) recordMediaTileMoveDirection(direction) + // Rail entry is the only move the engine can't resolve here; + // vertical moves to the header belong to the focus engine. if direction == .left, isLeadingGridColumn(index: index) { onRequestSideRailEntry() - } else if direction == .up, isTopGridRow(index: index) { - requestHeaderFocusFromSideRail(scrollProxy: scrollProxy) } } .id(item.id) @@ -119,17 +119,14 @@ struct CloudLibraryLibraryScreen: View, Equatable { onFocusTileID(titleID) scheduleFocusSettled(targetLabel: titleID.rawValue, settledTitleID: titleID) case .tab(let id): - lastFocusedHeaderTarget = target onFocusTileID(nil) NavigationPerformanceTracker.recordFocusTarget(surface: "library", target: "tab:\(id)") scheduleFocusSettled(targetLabel: "tab:\(id)", settledTitleID: nil) case .headerButton(let id): - lastFocusedHeaderTarget = target onFocusTileID(nil) NavigationPerformanceTracker.recordFocusTarget(surface: "library", target: "header:\(id)") scheduleFocusSettled(targetLabel: "header:\(id)", settledTitleID: nil) case .filter(let id): - lastFocusedHeaderTarget = target onFocusTileID(nil) NavigationPerformanceTracker.recordFocusTarget(surface: "library", target: "filter:\(id)") scheduleFocusSettled(targetLabel: "filter:\(id)", settledTitleID: nil) @@ -143,6 +140,12 @@ struct CloudLibraryLibraryScreen: View, Equatable { // Tab switch changes which items are visible — reset grid focus. lastFocusedGridTitleID = nil } + .onAppear { + consumeFocusHandoffIfNeeded(scrollProxy: scrollProxy) + } + .onChange(of: focusHandoffRequest) { _, _ in + consumeFocusHandoffIfNeeded(scrollProxy: scrollProxy) + } .background( GeometryReader { proxy in @@ -164,7 +167,8 @@ struct CloudLibraryLibraryScreen: View, Equatable { nonisolated static func == (lhs: CloudLibraryLibraryScreen, rhs: CloudLibraryLibraryScreen) -> Bool { lhs.state == rhs.state && lhs.tileLookup == rhs.tileLookup && - lhs.preferredTitleID == rhs.preferredTitleID + lhs.preferredTitleID == rhs.preferredTitleID && + lhs.focusHandoffRequest == rhs.focusHandoffRequest } } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenControls.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenControls.swift index 90b3ba2..549a190 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenControls.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenControls.swift @@ -65,7 +65,7 @@ struct LibraryTabButton: View { .fill(isSelected ? StratixTheme.Colors.focusTint : Color.white.opacity(isFocused ? 0.34 : 0.12)) .frame(width: isSelected ? 120 : 54, height: 5) } - .animation(.easeOut(duration: 0.14), value: isFocused) + .animation(StratixTheme.Motion.focus, value: isFocused) .gamePassFocusRing(isFocused: isFocused, cornerRadius: 22) } } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenFocus.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenFocus.swift index e7d26c2..e960e12 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenFocus.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenFocus.swift @@ -16,55 +16,16 @@ extension CloudLibraryLibraryScreen { return state.gridItems.first?.id } - func requestHeaderFocusFromSideRail(scrollProxy: ScrollViewProxy) { - if let remembered = lastFocusedHeaderTarget { - switch remembered { - case .tab(let id) where state.tabs.contains(where: { $0.id == id }): - requestHeaderFocus(.tab(id), scrollProxy: scrollProxy) - return - case .headerButton(let id) where id == "sort": - requestHeaderFocus(.headerButton(id), scrollProxy: scrollProxy) - return - case .headerButton(let id) where id == "sort" || id == "clear-filters": - focusedTarget = .headerButton(id) - case .headerButton(let id) where id == "clear-filters" && !state.activeFilterLabels.isEmpty: - requestHeaderFocus(.headerButton(id), scrollProxy: scrollProxy) - return - case .filter(let id) where state.filters.contains(where: { $0.id == id }): - requestHeaderFocus(.filter(id), scrollProxy: scrollProxy) - return - default: - break - } - } - - if state.tabs.contains(where: { $0.id == state.selectedTabID }) { - requestHeaderFocus(.tab(state.selectedTabID), scrollProxy: scrollProxy) - return - } - if let firstTab = state.tabs.first { - requestHeaderFocus(.tab(firstTab.id), scrollProxy: scrollProxy) - return - } - if !state.sortLabel.isEmpty { - requestHeaderFocus(.headerButton("sort"), scrollProxy: scrollProxy) - return - } + /// Applies a pending shell focus hand-off exactly once per generation, restoring the + /// remembered (or preferred) grid tile when the shell asks Library to claim focus. + func consumeFocusHandoffIfNeeded(scrollProxy: ScrollViewProxy) { + guard let request = focusHandoffRequest, + request.route == .library, + request.generation != consumedFocusHandoffGeneration else { return } + consumedFocusHandoffGeneration = request.generation requestGridFocus(scrollProxy: scrollProxy) } - func requestHeaderFocus(_ target: LibraryFocusTarget, scrollProxy: ScrollViewProxy) { - pendingFocusTask?.cancel() - pendingFocusTask = Task { @MainActor in - withAnimation(nil) { - scrollProxy.scrollTo(Self.headerAnchorID, anchor: .top) - } - await Task.yield() - guard !Task.isCancelled else { return } - focusedTarget = target - } - } - func requestGridFocus(scrollProxy: ScrollViewProxy, prefersFirstVisibleItem: Bool = false) { guard !state.gridItems.isEmpty else { return } let targetTitleID: TitleID? @@ -97,10 +58,6 @@ extension CloudLibraryLibraryScreen { } } - func isTopGridRow(index: Int) -> Bool { - index < cachedGridColumnCount - } - func isLeadingGridColumn(index: Int) -> Bool { index % cachedGridColumnCount == 0 } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenHeader.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenHeader.swift index 23d8c2c..aa6c875 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenHeader.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Library/CloudLibraryLibraryScreenHeader.swift @@ -20,10 +20,10 @@ extension CloudLibraryLibraryScreen { .focused($focusedTarget, equals: .tab(tab.id)) .onMoveCommand { direction in NavigationPerformanceTracker.recordRemoteMoveStart(surface: "library", direction: direction) - if direction == .left { + // Rail entry only from the leading tab; all other moves belong + // to the focus engine so a single press never moves focus twice. + if direction == .left, tab.id == state.tabs.first?.id { onRequestSideRailEntry() - } else if direction == .down { - requestGridFocus(scrollProxy: scrollProxy) } } } @@ -49,10 +49,9 @@ extension CloudLibraryLibraryScreen { .focused($focusedTarget, equals: .headerButton("sort")) .onMoveCommand { direction in NavigationPerformanceTracker.recordRemoteMoveStart(surface: "library", direction: direction) + // Sort is the leading focusable in its row, so left exits to the rail. if direction == .left { onRequestSideRailEntry() - } else if direction == .down { - requestGridFocus(scrollProxy: scrollProxy) } } @@ -61,11 +60,6 @@ extension CloudLibraryLibraryScreen { .focused($focusedTarget, equals: .headerButton("clear-filters")) .onMoveCommand { direction in NavigationPerformanceTracker.recordRemoteMoveStart(surface: "library", direction: direction) - if direction == .left { - onRequestSideRailEntry() - } else if direction == .down { - requestGridFocus(scrollProxy: scrollProxy) - } } } @@ -82,10 +76,8 @@ extension CloudLibraryLibraryScreen { .focused($focusedTarget, equals: .filter(filter.id)) .onMoveCommand { direction in NavigationPerformanceTracker.recordRemoteMoveStart(surface: "library", direction: direction) - if direction == .left { + if direction == .left, filter.id == state.filters.first?.id { onRequestSideRailEntry() - } else if direction == .down { - requestGridFocus(scrollProxy: scrollProxy) } } } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Presentation/CloudLibraryBrowseRouteHost.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Presentation/CloudLibraryBrowseRouteHost.swift index ab0a7d7..0d52764 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Presentation/CloudLibraryBrowseRouteHost.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Presentation/CloudLibraryBrowseRouteHost.swift @@ -13,14 +13,22 @@ struct CloudLibraryBrowseRouteHost: View { let presentation: CloudLibraryBrowseRoutePresentation let searchText: Binding let actions: CloudLibraryBrowseRouteActions + var focusHandoffRequest: CloudLibraryFocusState.ContentFocusRequest? = nil var body: some View { - switch presentation.browseRoute { - case .consoles: - CloudLibraryConsolesView(onRequestSideRailEntry: actions.requestSideRailEntry) - case .home, .library, .search: - libraryContent + ZStack { + switch presentation.browseRoute { + case .consoles: + CloudLibraryConsolesView( + onRequestSideRailEntry: actions.requestSideRailEntry, + focusHandoffRequest: focusHandoffRequest + ) + case .home, .library, .search: + libraryContent + } } + .animation(StratixTheme.Motion.routeTransition, value: presentation.browseRoute) + .animation(StratixTheme.Motion.routeTransition, value: presentation.loadState) } @ViewBuilder @@ -95,7 +103,8 @@ struct CloudLibraryBrowseRouteHost: View { onRequestSideRailEntry: actions.requestSideRailEntry, onFocusTileID: actions.homeFocusTileID, onSettledTileID: actions.homeSettledTileID, - tileLookup: presentation.homeTileLookup + tileLookup: presentation.homeTileLookup, + focusHandoffRequest: focusHandoffRequest ) .equatable() .modifier(RouteHomeRootAccessibilityModifier(isEnabled: shellBootstrapController.phase == .ready)) @@ -113,7 +122,8 @@ struct CloudLibraryBrowseRouteHost: View { onSelectFilter: actions.librarySelectFilter, onSelectSort: actions.librarySelectSort, onClearFilters: actions.libraryClearFilters, - onRequestSideRailEntry: actions.requestSideRailEntry + onRequestSideRailEntry: actions.requestSideRailEntry, + focusHandoffRequest: focusHandoffRequest ) .equatable() } @@ -129,7 +139,8 @@ struct CloudLibraryBrowseRouteHost: View { onClearQuery: actions.searchClearQuery, onSelectTile: actions.searchSelectTile, onFocusTileID: actions.searchFocusTileID, - onRequestSideRailEntry: actions.requestSideRailEntry + onRequestSideRailEntry: actions.requestSideRailEntry, + focusHandoffRequest: focusHandoffRequest ) .equatable() .searchable(text: searchText, prompt: "Search cloud titles") diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Presentation/CloudLibraryContentRouteHost.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Presentation/CloudLibraryContentRouteHost.swift index 93e58d7..d5cdf87 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Presentation/CloudLibraryContentRouteHost.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Presentation/CloudLibraryContentRouteHost.swift @@ -15,6 +15,7 @@ struct CloudLibraryContentRouteHost: View { let browsePresentation: CloudLibraryBrowseRoutePresentation let searchText: Binding let browseActions: CloudLibraryBrowseRouteActions + let contentFocusRequest: CloudLibraryFocusState.ContentFocusRequest? let detailPath: Binding<[TitleID]> let detailOriginRoute: AppRoute let viewModel: CloudLibraryViewModel @@ -36,18 +37,22 @@ struct CloudLibraryContentRouteHost: View { } } - @ViewBuilder - /// Chooses the current browse or utility destination for the CloudLibrary shell. + /// Chooses the current browse or utility destination for the CloudLibrary shell, + /// crossfading utility overlays in and out instead of hard-cutting. private var routeContent: some View { - if let utilityRoute { - utilityRouteContent(utilityRoute) - } else { - CloudLibraryBrowseRouteHost( - presentation: browsePresentation, - searchText: searchText, - actions: browseActions - ) + ZStack { + if let utilityRoute { + utilityRouteContent(utilityRoute) + } else { + CloudLibraryBrowseRouteHost( + presentation: browsePresentation, + searchText: searchText, + actions: browseActions, + focusHandoffRequest: contentFocusRequest + ) + } } + .animation(StratixTheme.Motion.routeTransition, value: utilityRoute) } @ViewBuilder diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Profile/CloudLibraryProfileView.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Profile/CloudLibraryProfileView.swift index 51abddf..2623209 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Profile/CloudLibraryProfileView.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Profile/CloudLibraryProfileView.swift @@ -91,7 +91,7 @@ struct CloudLibraryProfileView: View { if !profileDetail.isEmpty { Text(profileDetail) - .font(StratixTypography.rounded(14, weight: .medium, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(17, weight: .medium, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.textMuted) .fixedSize(horizontal: false, vertical: true) } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Root/CloudLibraryView.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Root/CloudLibraryView.swift index efa75ff..4d56546 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Root/CloudLibraryView.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Root/CloudLibraryView.swift @@ -52,9 +52,14 @@ struct CloudLibraryView: View { var body: some View { mountedShell .fullScreenCover(item: $activeStreamContext, onDismiss: handleActiveStreamDismissed) { ctx in - StreamControllerInputHost(onOverlayToggle: { - streamController.requestOverlayToggle() - }) { + StreamControllerInputHost( + onOverlayToggle: { + streamController.requestOverlayToggle() + }, + onMenuPress: { + streamController.requestOverlayToggle() + } + ) { StreamView(context: ctx) } .ignoresSafeArea() @@ -110,6 +115,7 @@ struct CloudLibraryView: View { } ) .opacity(visibility.opacity) + .animation(StratixTheme.Motion.routeTransition, value: visibility) .allowsHitTesting(visibility.allowsHitTesting) .accessibilityHidden(visibility.isAccessibilityHidden) .overlay(alignment: .topLeading) { diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Search/CloudLibrarySearchScreen.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Search/CloudLibrarySearchScreen.swift index d3eccaf..71dc506 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Search/CloudLibrarySearchScreen.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Search/CloudLibrarySearchScreen.swift @@ -17,6 +17,7 @@ struct CloudLibrarySearchScreen: View, Equatable { let onSelectTile: (MediaTileViewState) -> Void var onFocusTileID: (TitleID?) -> Void = { _ in } var onRequestSideRailEntry: () -> Void = {} + var focusHandoffRequest: CloudLibraryFocusState.ContentFocusRequest? = nil @Environment(\.dynamicTypeSize) private var dynamicTypeSize private enum SearchFocusTarget: Hashable { @@ -27,6 +28,8 @@ struct CloudLibrarySearchScreen: View, Equatable { @State private var cachedGridColumnCount: Int = Self.defaultGridColumnCount @State private var cachedColumns: [GridItem] = Self.defaultColumns @State private var focusSettler = FocusSettleDebouncer() + @State private var pendingFocusTask: Task? + @State private var consumedFocusHandoffGeneration: Int? private let gridItemWidth = StratixTheme.Search.gridItemWidth private let gridItemSpacing = StratixTheme.Search.gridItemSpacing @@ -48,32 +51,46 @@ struct CloudLibrarySearchScreen: View, Equatable { ScrollViewReader { scrollProxy in ScrollView { LazyVStack(alignment: .leading, spacing: StratixTheme.Search.sectionSpacing) { - if !trimmedQueryText.isEmpty { - if resultItems.isEmpty { + if trimmedQueryText.isEmpty { + // Landing state: show the full catalog to browse instead of a blank screen. + if browseItems.isEmpty { CloudLibraryStatusPanel( state: .init( kind: .empty, - title: "No matches", - message: "No titles matched \"\(queryTextValue)\". Try shorter keywords.", - primaryActionTitle: "Clear Search" - ), - onPrimaryAction: onClearQuery + title: "Nothing to browse yet", + message: "Load your Game Pass library to browse and search cloud titles.", + primaryActionTitle: nil + ) ) - .frame(height: 420) + .frame(minHeight: 600) } else { - Text("\(resultItems.count) results") - .font(StratixTypography.rounded(15, weight: .semibold, dynamicTypeSize: dynamicTypeSize)) - .foregroundStyle(StratixTheme.Colors.textMuted) - .padding(.horizontal, gridHorizontalPadding) + sectionLabel("Browse all \(totalLibraryCount) titles") - tileGrid(items: resultItems) + tileGrid(items: browseItems) } + } else if resultItems.isEmpty { + CloudLibraryStatusPanel( + state: .init( + kind: .empty, + title: "No matches", + message: "No titles matched \"\(queryTextValue)\". Try shorter keywords.", + primaryActionTitle: "Clear Search" + ), + onPrimaryAction: onClearQuery + ) + .frame(minHeight: 600) + } else { + sectionLabel(resultItems.count == 1 ? "1 result" : "\(resultItems.count) results") + + tileGrid(items: resultItems) } } .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, StratixTheme.Search.contentTopPadding) } .accessibilityIdentifier("route_search_root") .scrollIndicators(.hidden) + .scrollClipDisabled() .gamePassDisableSystemFocusEffect() .onChange(of: focusedTarget) { _, target in guard let target else { @@ -101,13 +118,27 @@ struct CloudLibrarySearchScreen: View, Equatable { } ) } + .onAppear { + consumeFocusHandoffIfNeeded() + } + .onChange(of: focusHandoffRequest) { _, _ in + consumeFocusHandoffIfNeeded() + } .onDisappear { focusSettler.cancel() + pendingFocusTask?.cancel() } } // MARK: - Tile Grid + private func sectionLabel(_ text: String) -> some View { + Text(text) + .font(StratixTypography.rounded(20, weight: .semibold, dynamicTypeSize: dynamicTypeSize)) + .foregroundStyle(StratixTheme.Colors.textSecondary) + .padding(.horizontal, gridHorizontalPadding) + } + @ViewBuilder private func tileGrid(items: [MediaTileViewState]) -> some View { LazyVGrid(columns: cachedColumns, alignment: .leading, spacing: gridItemSpacing) { @@ -133,6 +164,33 @@ struct CloudLibrarySearchScreen: View, Equatable { // MARK: - Focus management + /// Applies a pending shell focus hand-off exactly once per generation, landing on the + /// preferred (or first) visible tile when the shell asks Search to claim focus. + private func consumeFocusHandoffIfNeeded() { + guard let request = focusHandoffRequest, + request.route == .search, + request.generation != consumedFocusHandoffGeneration else { return } + consumedFocusHandoffGeneration = request.generation + requestPrimaryFocus() + } + + private func requestPrimaryFocus() { + let visibleItems = trimmedQueryText.isEmpty ? browseItems : resultItems + guard !visibleItems.isEmpty else { return } + let targetTitleID: TitleID + if let preferredTitleID, visibleItems.contains(where: { $0.titleID == preferredTitleID }) { + targetTitleID = preferredTitleID + } else { + targetTitleID = visibleItems[0].titleID + } + pendingFocusTask?.cancel() + pendingFocusTask = Task { @MainActor in + await Task.yield() + guard !Task.isCancelled else { return } + focusedTarget = .tile(targetTitleID) + } + } + private func scheduleFocusSettled(targetID: String) { focusSettler.schedule { NavigationPerformanceTracker.recordFocusSettled(surface: "search", target: targetID) @@ -162,7 +220,8 @@ struct CloudLibrarySearchScreen: View, Equatable { lhs.browseItems == rhs.browseItems && lhs.resultItems == rhs.resultItems && lhs.tileLookup == rhs.tileLookup && - lhs.preferredTitleID == rhs.preferredTitleID + lhs.preferredTitleID == rhs.preferredTitleID && + lhs.focusHandoffRequest == rhs.focusHandoffRequest } } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsComponents.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsComponents.swift index a497723..21eeb79 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsComponents.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsComponents.swift @@ -67,13 +67,13 @@ struct CloudLibrarySidebarButton: View { VStack(alignment: .leading, spacing: 3) { Text(title) - .font(StratixTypography.rounded(18, weight: .bold, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(20, weight: .bold, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(isSelected ? Color.black : StratixTheme.Colors.textPrimary) .lineLimit(1) if let subtitle, !subtitle.isEmpty { Text(subtitle) - .font(StratixTypography.rounded(12, weight: .medium, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(15, weight: .medium, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(isSelected ? Color.black.opacity(0.72) : StratixTheme.Colors.textMuted) .lineLimit(2) } @@ -141,7 +141,7 @@ struct CloudLibrarySettingsActionButton: View { Button(action: action) { FocusAwareView { isFocused in Label(title, systemImage: systemImage) - .font(StratixTypography.rounded(16, weight: .bold, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(18, weight: .bold, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(foreground(isFocused: isFocused)) .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading) .padding(.horizontal, 16) @@ -178,7 +178,7 @@ struct CloudLibraryStatPill: View { var body: some View { Label(text, systemImage: icon) - .font(StratixTypography.rounded(13, weight: .bold, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(16, weight: .bold, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.textSecondary) .padding(.horizontal, 12) .padding(.vertical, 8) @@ -200,7 +200,7 @@ struct CloudLibraryStatLine: View { .frame(width: 20) Text(text) - .font(StratixTypography.rounded(15, weight: .medium, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(18, weight: .medium, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.textSecondary) .fixedSize(horizontal: false, vertical: true) } @@ -223,7 +223,7 @@ struct CloudLibraryToggleRow: View { if let subtitle, !subtitle.isEmpty { Text(subtitle) - .font(StratixTypography.rounded(13, weight: .medium, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(16, weight: .medium, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.textMuted) .fixedSize(horizontal: false, vertical: true) } @@ -281,7 +281,7 @@ struct CloudLibrarySliderRow: View { Spacer() Text(formatter(value)) - .font(StratixTypography.rounded(14, weight: .bold, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(17, weight: .bold, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.focusTint) .monospacedDigit() } @@ -365,7 +365,7 @@ struct CloudLibrarySettingTag: View { var body: some View { Text(text) - .font(StratixTypography.rounded(10, weight: .bold, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(13, weight: .bold, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.textSecondary) .padding(.horizontal, 8) .padding(.vertical, 4) diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsSidebar.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsSidebar.swift index 6992b68..af57880 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsSidebar.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsSidebar.swift @@ -11,7 +11,7 @@ extension CloudLibrarySettingsView { VStack(alignment: .leading, spacing: 12) { Text("Settings") - .font(StratixTypography.rounded(13, weight: .bold, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(15, weight: .bold, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.textMuted) .textCase(.uppercase) @@ -25,10 +25,12 @@ extension CloudLibrarySettingsView { selectedPane = pane } .focused($focusedPane, equals: pane) - .defaultFocus($focusedPane, pane) .onMoveCommand(perform: requestSideRailEntryOnLeft) } } + // A single default-focus declaration pointing at the selected pane; declaring + // one per row made every pane claim default focus and left the engine to guess. + .defaultFocus($focusedPane, selectedPane) Spacer(minLength: 0) @@ -66,13 +68,13 @@ extension CloudLibrarySettingsView { .lineLimit(1) Text(profileStatusText) - .font(StratixTypography.rounded(13, weight: .semibold, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(16, weight: .semibold, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.textSecondary) .lineLimit(1) if !profileStatusDetail.isEmpty { Text(profileStatusDetail) - .font(StratixTypography.rounded(12, weight: .medium, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(15, weight: .medium, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.textMuted) .fixedSize(horizontal: false, vertical: true) } diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsView.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsView.swift index f29febe..a27a2fe 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsView.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Settings/CloudLibrarySettingsView.swift @@ -87,6 +87,7 @@ struct CloudLibrarySettingsView: View { } } .padding(28) + .animation(StratixTheme.Motion.routeTransition, value: selectedPane) } .frame(maxWidth: 1510, alignment: .topLeading) } @@ -114,7 +115,7 @@ struct CloudLibrarySettingsView: View { } Text(selectedPane.subtitle) - .font(StratixTypography.rounded(15, weight: .medium, dynamicTypeSize: dynamicTypeSize)) + .font(StratixTypography.rounded(18, weight: .medium, dynamicTypeSize: dynamicTypeSize)) .foregroundStyle(StratixTheme.Colors.textSecondary) .fixedSize(horizontal: false, vertical: true) diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Shell/CloudLibraryShellHost.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Shell/CloudLibraryShellHost.swift index b2689dc..dcd3c8a 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Shell/CloudLibraryShellHost.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/Shell/CloudLibraryShellHost.swift @@ -51,16 +51,23 @@ struct CloudLibraryShellHost: View { } /// Mounts the hosted shell and applies shell-level remote-command handlers. + /// The exit handler is passed as an optional closure so toggling back-consumption + /// never changes the shell's structural identity (a conditional modifier branch + /// would rebuild the whole subtree and drop focus/scroll state). var body: some View { hostedShell - .applyExitCommandIfNeeded(shouldConsumeBackEvent) { - handleBack() - } + .onExitCommand(perform: exitCommandAction) .onPlayPauseCommand { handleSettingsShortcut() } } + /// Optional so back-consumption can toggle without changing the view tree's structure. + private var exitCommandAction: (() -> Void)? { + guard shouldConsumeBackEvent else { return nil } + return { handleBack() } + } + /// Builds the shell container and schedules presentation rebuild tasks off the current route state. private var hostedShell: some View { CloudLibraryShellView( @@ -129,6 +136,7 @@ struct CloudLibraryShellHost: View { browsePresentation: presentationStore.browseRoutePresentation, searchText: queryState.searchText, browseActions: browseActions, + contentFocusRequest: focusState.contentFocusRequest, detailPath: detailPathBinding, detailOriginRoute: routeState.browseRouteToAppRoute(routeState.browseRoute), viewModel: viewModel, @@ -464,14 +472,3 @@ enum CloudLibraryShellContentMode: Equatable { case utility case detail } - -private extension View { - @ViewBuilder - func applyExitCommandIfNeeded(_ enabled: Bool, perform action: @escaping () -> Void) -> some View { - if enabled { - onExitCommand(perform: action) - } else { - self - } - } -} diff --git a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/State/CloudLibraryFocusState.swift b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/State/CloudLibraryFocusState.swift index c489d86..ff3192e 100644 --- a/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/State/CloudLibraryFocusState.swift +++ b/Apps/Stratix/Sources/Stratix/Features/CloudLibrary/State/CloudLibraryFocusState.swift @@ -9,11 +9,20 @@ import StratixModels @MainActor /// Stores shell-owned focus facts that survive route rebuilds and feed hero/background restoration. final class CloudLibraryFocusState { + /// A shell-issued request for a content surface to claim focus. Screens consume the + /// request by generation so a stale request is never re-applied after a detail pop. + /// Nonisolated so screen Equatable conformances can compare requests off the main actor. + nonisolated struct ContentFocusRequest: Equatable, Sendable { + let route: CloudLibraryBrowseRoute + let generation: Int + } + var focusedTileIDsByRoute: [CloudLibraryBrowseRoute: TitleID] = [:] var settledHomeHeroTileID: TitleID? var settledLibraryHeroTileID: TitleID? var isSideRailExpanded = false var hasRequestedInitialContentFocus = false + private(set) var contentFocusRequest: ContentFocusRequest? /// Returns the last focused title for the given browse route when one exists. func focusedTileID(for route: CloudLibraryBrowseRoute) -> TitleID? { @@ -49,9 +58,14 @@ final class CloudLibraryFocusState { } } - /// Exists as the shell-facing content-focus hook even when the underlying focus path is framework-owned. + /// Asks the active browse surface to claim focus (side-rail hand-off, back navigation, + /// stream dismissal, bootstrap). Each call bumps the generation so screens can tell a + /// fresh request from one they already consumed. func requestTopContentFocus(for route: CloudLibraryBrowseRoute) { - _ = route + contentFocusRequest = ContentFocusRequest( + route: route, + generation: (contentFocusRequest?.generation ?? 0) + 1 + ) } /// Exists as the shell-facing utility-focus hook even when the utility surface owns the concrete focus move. diff --git a/Apps/Stratix/Sources/Stratix/Features/Guide/GuideRows.swift b/Apps/Stratix/Sources/Stratix/Features/Guide/GuideRows.swift index 1afc6d6..dfad1e3 100644 --- a/Apps/Stratix/Sources/Stratix/Features/Guide/GuideRows.swift +++ b/Apps/Stratix/Sources/Stratix/Features/Guide/GuideRows.swift @@ -431,7 +431,7 @@ private struct GuideCompactFocusModifier: ViewModifier { .scaleEffect(isFocused ? focusScale : 1.0) .shadow(color: .black.opacity(isFocused ? 0.34 : 0.10), radius: isFocused ? 16 : 6, y: isFocused ? 8 : 3) .zIndex(isFocused ? 10 : 0) - .animation(reduceMotion ? nil : .easeOut(duration: 0.14), value: isFocused) + .animation(reduceMotion ? nil : StratixTheme.Motion.focus, value: isFocused) } } diff --git a/Apps/Stratix/Sources/Stratix/Features/Streaming/StreamControllerInputHost.swift b/Apps/Stratix/Sources/Stratix/Features/Streaming/StreamControllerInputHost.swift index 4beafa2..df2e07b 100644 --- a/Apps/Stratix/Sources/Stratix/Features/Streaming/StreamControllerInputHost.swift +++ b/Apps/Stratix/Sources/Stratix/Features/Streaming/StreamControllerInputHost.swift @@ -17,29 +17,46 @@ import GameController struct StreamControllerInputHost: UIViewControllerRepresentable { let content: Content let onOverlayToggle: (() -> Void)? + let onMenuPress: (() -> Void)? - init(onOverlayToggle: (() -> Void)? = nil, @ViewBuilder content: () -> Content) { + init( + onOverlayToggle: (() -> Void)? = nil, + onMenuPress: (() -> Void)? = nil, + @ViewBuilder content: () -> Content + ) { self.onOverlayToggle = onOverlayToggle + self.onMenuPress = onMenuPress self.content = content() } func makeUIViewController(context: Context) -> StreamControllerInputViewController { - StreamControllerInputViewController(rootView: content, onOverlayToggle: onOverlayToggle) + StreamControllerInputViewController( + rootView: content, + onOverlayToggle: onOverlayToggle, + onMenuPress: onMenuPress + ) } func updateUIViewController(_ uiViewController: StreamControllerInputViewController, context: Context) { uiViewController.hostingController.rootView = content uiViewController.onOverlayToggle = onOverlayToggle + uiViewController.onMenuPress = onMenuPress } } final class StreamControllerInputViewController: GCEventViewController { let hostingController: UIHostingController var onOverlayToggle: (() -> Void)? + var onMenuPress: (() -> Void)? - init(rootView: Content, onOverlayToggle: (() -> Void)? = nil) { + init( + rootView: Content, + onOverlayToggle: (() -> Void)? = nil, + onMenuPress: (() -> Void)? = nil + ) { self.hostingController = UIHostingController(rootView: rootView) self.onOverlayToggle = onOverlayToggle + self.onMenuPress = onMenuPress super.init(nibName: nil, bundle: nil) } @@ -89,7 +106,13 @@ final class StreamControllerInputViewController: GCEventViewContr onOverlayToggle?() return } - if shouldSwallowMenuPress(presses) { return } + if shouldSwallowMenuPress(presses) { + // Still swallow so tvOS never dismisses the stream cover, but give the + // Siri Remote's back button a job: surface (or hide) the stream overlay + // instead of silently doing nothing. + onMenuPress?() + return + } super.pressesEnded(presses, with: event) } @@ -111,8 +134,13 @@ final class StreamControllerInputViewController: GCEventViewContr struct StreamControllerInputHost: View { let content: Content - init(onOverlayToggle: (() -> Void)? = nil, @ViewBuilder content: () -> Content) { + init( + onOverlayToggle: (() -> Void)? = nil, + onMenuPress: (() -> Void)? = nil, + @ViewBuilder content: () -> Content + ) { _ = onOverlayToggle + _ = onMenuPress self.content = content() } diff --git a/Apps/Stratix/Sources/Stratix/Integration/UITestHarness/ShellUITestHarnessView.swift b/Apps/Stratix/Sources/Stratix/Integration/UITestHarness/ShellUITestHarnessView.swift index 79f475e..4795fb8 100644 --- a/Apps/Stratix/Sources/Stratix/Integration/UITestHarness/ShellUITestHarnessView.swift +++ b/Apps/Stratix/Sources/Stratix/Integration/UITestHarness/ShellUITestHarnessView.swift @@ -91,9 +91,15 @@ struct ShellUITestHarnessView: View { ).shouldConsumeBackEvent } + /// Optional so back-consumption can toggle without changing the view tree's structure. + private var exitCommandAction: (() -> Void)? { + guard shouldConsumeBackEvent else { return nil } + return { handleLocalBack() } + } + var body: some View { shellScaffold - .applyExitCommandIfNeeded(shouldConsumeBackEvent, perform: handleLocalBack) + .onExitCommand(perform: exitCommandAction) .onPlayPauseCommand { handleSettingsShortcut() } @@ -450,14 +456,3 @@ private struct ShellUITestStreamOverlay: View { } } } - -private extension View { - @ViewBuilder - func applyExitCommandIfNeeded(_ enabled: Bool, perform action: @escaping () -> Void) -> some View { - if enabled { - onExitCommand(perform: action) - } else { - self - } - } -} diff --git a/Apps/Stratix/Sources/Stratix/Shared/Components/ChipGroupView.swift b/Apps/Stratix/Sources/Stratix/Shared/Components/ChipGroupView.swift index 82bdceb..3fd7306 100644 --- a/Apps/Stratix/Sources/Stratix/Shared/Components/ChipGroupView.swift +++ b/Apps/Stratix/Sources/Stratix/Shared/Components/ChipGroupView.swift @@ -77,10 +77,10 @@ struct MetadataChip: View { HStack(spacing: 8) { if let systemImage = chip.systemImage { Image(systemName: systemImage) - .font(.system(size: 12, weight: .bold)) + .font(.system(size: 14, weight: .bold)) } Text(chip.label) - .font(.system(size: 14, weight: .semibold, design: .rounded)) + .font(.system(size: 17, weight: .semibold, design: .rounded)) } .foregroundStyle(chip.style == .accent ? StratixTheme.Colors.focusTint : StratixTheme.Colors.textPrimary) .padding(.horizontal, 12) diff --git a/Apps/Stratix/Sources/Stratix/Shared/Components/GlassCard.swift b/Apps/Stratix/Sources/Stratix/Shared/Components/GlassCard.swift index b42d8fd..adc0c54 100644 --- a/Apps/Stratix/Sources/Stratix/Shared/Components/GlassCard.swift +++ b/Apps/Stratix/Sources/Stratix/Shared/Components/GlassCard.swift @@ -96,7 +96,7 @@ struct FocusRingModifier: ViewModifier { .scaleEffect(isFocused ? focusScale : 1.0) .shadow(color: .black.opacity(isFocused ? 0.56 : 0.16), radius: isFocused ? 30 : 12, y: isFocused ? 18 : 6) .zIndex(isFocused ? 10 : 0) - .animation(reduceMotion ? nil : .easeOut(duration: 0.14), value: isFocused) + .animation(reduceMotion ? nil : StratixTheme.Motion.focus, value: isFocused) } } diff --git a/Apps/Stratix/Sources/Stratix/Shared/Components/MediaTileView.swift b/Apps/Stratix/Sources/Stratix/Shared/Components/MediaTileView.swift index 3e260bd..fd0ecfa 100644 --- a/Apps/Stratix/Sources/Stratix/Shared/Components/MediaTileView.swift +++ b/Apps/Stratix/Sources/Stratix/Shared/Components/MediaTileView.swift @@ -13,20 +13,19 @@ func recordMediaTileMoveDirection(_ direction: MoveCommandDirection) { struct MediaTileView: View { let state: MediaTileViewState let onSelect: () -> Void - /// Allows specific callers to override the environment focus state when they need a - /// deterministic visual focus treatment during routing or restoration. - var forcedFocus: Bool? = nil var presentation: MediaTilePresentation = .standard var artworkOverrideSize: CGSize? = nil - private let focusScale: CGFloat = 1.0 - private let titleBlockHeight: CGFloat = 46 - private let subtitleBlockHeight: CGFloat = 22 + private let focusScale: CGFloat = 1.05 + private let titleBlockHeight: CGFloat = 56 + private let subtitleBlockHeight: CGFloat = 24 var body: some View { Button(action: onSelect) { - FocusAwareView { labelFocused in - let activeFocus = forcedFocus ?? labelFocused + // Environment focus is the single source of truth for the tile's visual state; + // a coordinator-driven override could disagree with the engine mid-transition + // and paint the ring on two tiles at once. + FocusAwareView { activeFocus in VStack(alignment: .leading, spacing: 12) { artworkView(activeFocus: activeFocus) @@ -114,21 +113,22 @@ struct MediaTileView: View { } .frame(width: artworkSize.width, height: artworkSize.height) .scaleEffect(activeFocus ? focusScale : 1.0) + .animation(StratixTheme.Motion.focus, value: activeFocus) } /// Keeps title, subtitle, and caption heights stable so rows do not jump as focus changes. private func titleBlock(activeFocus: Bool) -> some View { VStack(alignment: .leading, spacing: 4) { Text(state.title) - .font(.system(size: 18, weight: .semibold, design: .rounded)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) .foregroundStyle(activeFocus ? Color.white : Color.white.opacity(0.92)) .lineLimit(2) .frame(width: artworkSize.width, height: titleBlockHeight, alignment: .topLeading) if let subtitle = state.subtitle, !subtitle.isEmpty { Text(subtitle) - .font(.system(size: 15, weight: .medium, design: .rounded)) - .foregroundStyle(Color.white.opacity(activeFocus ? 0.72 : 0.52)) + .font(.system(size: 18, weight: .medium, design: .rounded)) + .foregroundStyle(Color.white.opacity(activeFocus ? 0.80 : 0.65)) .lineLimit(1) .frame(width: artworkSize.width, height: subtitleBlockHeight, alignment: .topLeading) } else { @@ -138,8 +138,8 @@ struct MediaTileView: View { if let caption = state.caption, !caption.isEmpty { Text(caption) - .font(.system(size: 13, weight: .regular, design: .rounded)) - .foregroundStyle(Color.white.opacity(activeFocus ? 0.52 : 0.36)) + .font(.system(size: 16, weight: .regular, design: .rounded)) + .foregroundStyle(Color.white.opacity(activeFocus ? 0.70 : 0.55)) .lineLimit(1) .frame(width: artworkSize.width, alignment: .leading) } @@ -154,7 +154,7 @@ struct MediaTileView: View { Color.black HStack(spacing: 24) { MediaTileView(state: CloudLibraryPreviewData.tileStates[0], onSelect: {}) - MediaTileView(state: CloudLibraryPreviewData.tileStates[1], onSelect: {}, forcedFocus: true) + MediaTileView(state: CloudLibraryPreviewData.tileStates[1], onSelect: {}) } .padding(60) } diff --git a/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailActionList.swift b/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailActionList.swift index 1554d3b..9d4e4f2 100644 --- a/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailActionList.swift +++ b/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailActionList.swift @@ -91,7 +91,7 @@ private struct SideRailActionButton: View { ) ) .scaleEffect(isFocused ? 1.03 : 1.0) - .animation(.easeInOut(duration: 0.13), value: isFocused) + .animation(StratixTheme.Motion.focus, value: isFocused) .gamePassFocusRing(isFocused: isFocused, cornerRadius: 14) } } diff --git a/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailFocusCoordinator.swift b/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailFocusCoordinator.swift index b119a14..07ddb49 100644 --- a/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailFocusCoordinator.swift +++ b/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailFocusCoordinator.swift @@ -104,9 +104,17 @@ extension SideRailNavigationView { } /// Expands the rail and schedules focus after the state change has been committed by SwiftUI. + /// Also claims focus when the rail was expanded externally (shell binding flipped before the + /// onChange fired) — otherwise the rail opens unfocused and the engine picks an arbitrary row. func expandRailAndFocusPreferredTarget() { guard !forceCollapsed else { return } guard !isRailExpanded else { + if focusedTarget == nil { + didExplicitlyEnterRail = true + scheduleRailFocus { + focusedTarget = preferredEntryTarget + } + } onExpansionChanged?(true) return } diff --git a/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailNavList.swift b/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailNavList.swift index d1bc600..9f0ba1c 100644 --- a/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailNavList.swift +++ b/Apps/Stratix/Sources/Stratix/Shared/Components/SideRailNavList.swift @@ -89,7 +89,7 @@ private struct SideRailNavButton: View { .fill(Color.clear) ) .scaleEffect(isFocused ? 1.01 : 1.0) - .animation(.easeOut(duration: 0.12), value: isFocused) + .animation(StratixTheme.Motion.focus, value: isFocused) .gamePassFocusRing(isFocused: isExpanded && isFocused, cornerRadius: 14) } } diff --git a/Apps/Stratix/Sources/Stratix/Shared/Theme/StratixDesignTokens.swift b/Apps/Stratix/Sources/Stratix/Shared/Theme/StratixDesignTokens.swift index f198a2c..fac863e 100644 --- a/Apps/Stratix/Sources/Stratix/Shared/Theme/StratixDesignTokens.swift +++ b/Apps/Stratix/Sources/Stratix/Shared/Theme/StratixDesignTokens.swift @@ -152,7 +152,7 @@ enum StratixTheme { enum Search { static let sectionSpacing: CGFloat = 24 - static let contentTopPadding: CGFloat = 8 + static let contentTopPadding: CGFloat = 24 static let gridItemWidth: CGFloat = 252 static let gridItemSpacing: CGFloat = 22 static let gridHorizontalPadding: CGFloat = 16 @@ -181,10 +181,19 @@ enum StratixTheme { static let accent = Color(red: 0.36, green: 0.82, blue: 0.33) static let textPrimary = Color.white static let textSecondary = Color.white.opacity(0.75) - static let textMuted = Color.white.opacity(0.55) + static let textMuted = Color.white.opacity(0.65) static let warning = Color.orange } + enum Motion { + /// Crossfade applied when the shell swaps browse routes, utility overlays, or load states. + static let routeTransition = Animation.easeInOut(duration: 0.22) + /// Shared duration for focus scale/ring feedback so cards and buttons move in step. + static let focusDuration: Double = 0.14 + /// Shared curve for focus scale/ring feedback. + static let focus = Animation.easeOut(duration: focusDuration) + } + enum Fonts { static let shellTitle = Font.system(size: 22, weight: .semibold, design: .rounded) static let nav = Font.system(size: 16, weight: .semibold, design: .rounded) diff --git a/Apps/Stratix/Sources/Stratix/ViewState/CloudLibraryViewState.swift b/Apps/Stratix/Sources/Stratix/ViewState/CloudLibraryViewState.swift index e2b3a47..8bbb019 100644 --- a/Apps/Stratix/Sources/Stratix/ViewState/CloudLibraryViewState.swift +++ b/Apps/Stratix/Sources/Stratix/ViewState/CloudLibraryViewState.swift @@ -40,15 +40,15 @@ enum CloudLibrarySettingsPane: String, CaseIterable, Identifiable, Hashable, Sen case .overview: return "Overview" case .stream: - return "Cloud Stream Settings" + return "Streaming" case .controller: return "Controller" case .videoAudio: - return "Video / Audio" + return "Video & Audio" case .interface: - return "Interface / Accessibility" + return "Appearance" case .diagnostics: - return "Diagnostics / Advanced" + return "Advanced" } } @@ -56,17 +56,17 @@ enum CloudLibrarySettingsPane: String, CaseIterable, Identifiable, Hashable, Sen var subtitle: String { switch self { case .overview: - return "Profile, quick actions, and shell status" + return "Your account, library status, and quick actions" case .stream: - return "Quality, codec, bitrate, latency, and stream overlay settings" + return "Streaming quality and performance" case .controller: - return "Input feel, mappings, deadzone, and sensitivity tuning" + return "Controller feel and button behavior" case .videoAudio: - return "Display and audio preferences modeled after the web client" + return "Picture and sound preferences" case .interface: - return "TV comfort and shell accessibility controls" + return "On-screen text, comfort, and accessibility" case .diagnostics: - return "Advanced diagnostics and debug toggles" + return "Debug tools and experimental options" } } @@ -88,12 +88,14 @@ enum CloudLibrarySettingsPane: String, CaseIterable, Identifiable, Hashable, Sen } } - /// Filters the pane list for the simplified settings mode. + /// Filters the pane list for the simplified settings mode. Basic mode keeps the panes a + /// player actually reaches for (account, stream quality, controller feel, appearance); + /// video tuning and debug tooling stay behind Advanced. static func visibleCases(isAdvanced: Bool) -> [CloudLibrarySettingsPane] { if isAdvanced { return allCases } - return [.overview, .stream, .interface] + return [.overview, .stream, .controller, .interface] } } diff --git a/Apps/Stratix/Tests/StratixTests/CloudLibrary/CloudLibraryFocusStateTests.swift b/Apps/Stratix/Tests/StratixTests/CloudLibrary/CloudLibraryFocusStateTests.swift index 1cef36c..c53e9f4 100644 --- a/Apps/Stratix/Tests/StratixTests/CloudLibrary/CloudLibraryFocusStateTests.swift +++ b/Apps/Stratix/Tests/StratixTests/CloudLibrary/CloudLibraryFocusStateTests.swift @@ -54,6 +54,29 @@ final class CloudLibraryFocusStateTests: XCTestCase { XCTAssertEqual(focusState.focusedTileID(for: .home), homeID) } + @MainActor + func testRequestTopContentFocusPublishesGenerationalRequest() { + let focusState = CloudLibraryFocusState() + XCTAssertNil(focusState.contentFocusRequest) + + focusState.requestTopContentFocus(for: .home) + XCTAssertEqual( + focusState.contentFocusRequest, + CloudLibraryFocusState.ContentFocusRequest(route: .home, generation: 1) + ) + + focusState.requestTopContentFocus(for: .library) + XCTAssertEqual( + focusState.contentFocusRequest, + CloudLibraryFocusState.ContentFocusRequest(route: .library, generation: 2) + ) + + // Repeated requests for the same route still bump the generation so screens + // can consume each hand-off exactly once. + focusState.requestTopContentFocus(for: .library) + XCTAssertEqual(focusState.contentFocusRequest?.generation, 3) + } + @MainActor func testRequestUtilityFocusLeavesSideRailTokensUnchanged() { let focusState = CloudLibraryFocusState() diff --git a/Apps/Stratix/Tests/StratixTests/CloudLibrary/CloudLibrarySettingsPaneTests.swift b/Apps/Stratix/Tests/StratixTests/CloudLibrary/CloudLibrarySettingsPaneTests.swift index 4cf80b1..664a09c 100644 --- a/Apps/Stratix/Tests/StratixTests/CloudLibrary/CloudLibrarySettingsPaneTests.swift +++ b/Apps/Stratix/Tests/StratixTests/CloudLibrary/CloudLibrarySettingsPaneTests.swift @@ -25,11 +25,22 @@ final class CloudLibrarySettingsPaneTests: XCTestCase { XCTAssertEqual( CloudLibrarySettingsBindings.resolvedPane( currentPane: .diagnostics, - storedRawValue: CloudLibrarySettingsPane.controller.rawValue, + storedRawValue: CloudLibrarySettingsPane.diagnostics.rawValue, isAdvancedMode: false, restoreStoredSelection: true ), .overview ) } + + func testVisibleCases_basicModeKeepsPlayerFacingPanes() { + XCTAssertEqual( + CloudLibrarySettingsPane.visibleCases(isAdvanced: false), + [.overview, .stream, .controller, .interface] + ) + XCTAssertEqual( + CloudLibrarySettingsPane.visibleCases(isAdvanced: true), + CloudLibrarySettingsPane.allCases + ) + } }