From b26b7d342f0c785d5f1e506950829d5a35d09920 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 3 Oct 2026 23:24:50 +0200 Subject: [PATCH] Keep navigation state in a router shared by both iOS layouts The tab bar and sidebar layouts each kept their own navigation paths and selection, so swapping between them (resizing an iPad Stage Manager window across the size-class breakpoint) lost every pushed screen and re-applied the startup tab. NavigationRouter now holds the selected section, a typed stack per section keyed by SidebarItem.id, the search text and the sidebar manager. Both layouts select with SidebarItem and render the router's state; navigate(to:) pushes onto it directly instead of going through an observed pendingNavigation, so a request made before a layout appears is not lost, and a destination pushed before startup keeps its section. When a layout can't show the selection (a sidebar channel in the tab bar, a section hidden by customization) it lands on the section that can host it with that screen pushed first. This also fixes keyboard section commands in the tab bar layout and the coordinator's stale tab on iPad. AppTab, pendingNavigation, selectedSidebarItem and the unused SheetDestination/path API are gone, and the iOS 18 and 26.1 root views are merged into AdaptiveTabView. --- Yattee/ContentView.swift | 152 +------ Yattee/Core/NavigationCommands.swift | 18 +- Yattee/Models/Navigation/AppTab.swift | 53 --- .../Navigation/NavigationCoordinator.swift | 92 +--- .../Navigation/NavigationRouter.swift | 146 ++++++ Yattee/Views/Home/HomeView.swift | 6 +- Yattee/Views/Navigation/CompactTabView.swift | 294 ++---------- Yattee/Views/Navigation/UnifiedTabView.swift | 426 ++++-------------- YatteeTests/NavigationTests.swift | 146 ++++-- 9 files changed, 423 insertions(+), 910 deletions(-) delete mode 100644 Yattee/Models/Navigation/AppTab.swift create mode 100644 Yattee/Services/Navigation/NavigationRouter.swift diff --git a/Yattee/ContentView.swift b/Yattee/ContentView.swift index 6d1fd076..fb082182 100644 --- a/Yattee/ContentView.swift +++ b/Yattee/ContentView.swift @@ -14,11 +14,7 @@ struct ContentView: View { Group { if let appEnvironment { #if os(iOS) - if #available(iOS 26.1, *) { - iOS26AdaptiveTabView(appEnvironment: appEnvironment) - } else { - iOS18AdaptiveTabView(appEnvironment: appEnvironment) - } + AdaptiveTabView(appEnvironment: appEnvironment) #elseif os(macOS) unifiedContentView(appEnvironment: appEnvironment) #elseif os(tvOS) @@ -37,20 +33,10 @@ struct ContentView: View { private func unifiedContentView(appEnvironment: AppEnvironment) -> some View { ZStack(alignment: .bottom) { #if os(macOS) - UnifiedTabView( - selectedTab: Binding( - get: { appEnvironment.navigationCoordinator.selectedTab }, - set: { appEnvironment.navigationCoordinator.selectedTab = $0 } - ) - ) + UnifiedTabView(router: appEnvironment.navigationCoordinator.router) .environment(appEnvironment.settingsManager) #elseif os(tvOS) - UnifiedTabView( - selectedTab: Binding( - get: { appEnvironment.navigationCoordinator.selectedTab }, - set: { appEnvironment.navigationCoordinator.selectedTab = $0 } - ) - ) + UnifiedTabView(router: appEnvironment.navigationCoordinator.router) .environment(appEnvironment.settingsManager) #endif @@ -178,13 +164,15 @@ struct ContentView: View { #endif } -// MARK: - iOS 18+ Adaptive Tab View +// MARK: - Adaptive Tab View #if os(iOS) /// Switches between CompactTabView and UnifiedTabView based on horizontal size class. /// Compact width (iPhone, iPad Stage Manager small): CompactTabView with settings-based customization /// Regular width (iPad full, iPad larger windows): UnifiedTabView with sidebar adaptable -struct iOS18AdaptiveTabView: View { +/// Both render `NavigationRouter` state, so swapping between them keeps the +/// selection and every stack. +struct AdaptiveTabView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass let appEnvironment: AppEnvironment @@ -212,20 +200,17 @@ struct iOS18AdaptiveTabView: View { var body: some View { ZStack(alignment: .bottom) { if horizontalSizeClass == .compact || UIDevice.current.userInterfaceIdiom == .phone { - CompactTabView() + CompactTabView(router: appEnvironment.navigationCoordinator.router) .environment(appEnvironment.settingsManager) } else { - UnifiedTabView( - selectedTab: Binding( - get: { appEnvironment.navigationCoordinator.selectedTab }, - set: { appEnvironment.navigationCoordinator.selectedTab = $0 } - ) - ) + UnifiedTabView(router: appEnvironment.navigationCoordinator.router) .environment(appEnvironment.settingsManager) } - // Mini player overlay - miniPlayerOverlay + // iOS 26.1+ shows the mini player as the tab bar's bottom accessory + if !Self.usesTabBottomAccessory { + miniPlayerOverlay + } // Black overlay when player is fully expanded // Hides scaled content behind player sheet, removed during dismiss gesture for parallax effect @@ -278,6 +263,10 @@ struct iOS18AdaptiveTabView: View { } } + private static var usesTabBottomAccessory: Bool { + if #available(iOS 26.1, *) { true } else { false } + } + @ViewBuilder private var miniPlayerOverlay: some View { let playerState = appEnvironment.playerService.state @@ -307,113 +296,6 @@ struct iOS18AdaptiveTabView: View { } } -// MARK: - iOS 26+ Adaptive Tab View - -/// iOS 26.1+ version with bottom accessory mini player support. -/// Uses UnifiedTabView with sidebarAdaptable for regular width, CompactTabView for compact. -@available(iOS 26.1, *) -struct iOS26AdaptiveTabView: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - let appEnvironment: AppEnvironment - - @State private var showingMiniPlayerQueueSheet = false - @State private var showingMiniPlayerPlaylistSheet = false - - /// Whether to show the black overlay that covers scaled content when player is expanded - private var shouldShowExpandedOverlay: Bool { - let nav = appEnvironment.navigationCoordinator - // Show overlay only when: - // - Player window is actually visible (not just isPlayerExpanded intent) - // - Expand animation has completed (not animating) - // - Dismiss gesture is not active (so user can see scaled content during drag) - return nav.isPlayerWindowVisible && !nav.isPlayerSheetAnimating && !nav.isPlayerDismissGestureActive - } - - /// Whether to show loading spinner while waiting for player window to appear - private var shouldShowExpandPendingSpinner: Bool { - let nav = appEnvironment.navigationCoordinator - // Show spinner when expand is requested but window isn't visible yet - return nav.isPlayerExpanded && !nav.isPlayerWindowVisible && !nav.isPlayerSheetAnimating - } - - var body: some View { - ZStack { - Group { - if horizontalSizeClass == .compact || UIDevice.current.userInterfaceIdiom == .phone { - // Compact: Use CompactTabView with settings-based customization - // No bottom accessory since we're not using sidebarAdaptable style - CompactTabView() - .environment(appEnvironment.settingsManager) - } else { - // Regular: Use UnifiedTabView which has bottom accessory support via sidebarAdaptable - UnifiedTabView( - selectedTab: Binding( - get: { appEnvironment.navigationCoordinator.selectedTab }, - set: { appEnvironment.navigationCoordinator.selectedTab = $0 } - ) - ) - .environment(appEnvironment.settingsManager) - } - } - - // Black overlay when player is fully expanded - // Hides scaled content behind player sheet, removed during dismiss gesture for parallax effect - if shouldShowExpandedOverlay { - Color.black - .ignoresSafeArea() - .transaction { $0.animation = nil } - } - - // Loading spinner when expand is pending but window not yet visible - // This handles the case when scene is transitioning (e.g., Control Center) - if shouldShowExpandPendingSpinner { - ZStack { - Color.black.opacity(0.7) - ProgressView() - .tint(.white) - .scaleEffect(1.2) - } - .ignoresSafeArea() - .transition(.opacity.animation(.easeInOut(duration: 0.2))) - } - } - .onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in - presentExpandedPlayer() - } - .onChange(of: appEnvironment.navigationCoordinator.isPlayerExpanded) { _, isExpanded in - if !isExpanded { - ExpandedPlayerWindowManager.shared.hide() - } - } - .onChange(of: appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented, initial: true) { _, newValue in - showingMiniPlayerQueueSheet = newValue - } - .onChange(of: appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented, initial: true) { _, newValue in - showingMiniPlayerPlaylistSheet = newValue - } - .sheet(isPresented: $showingMiniPlayerQueueSheet, onDismiss: { - appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = false - }) { - QueueManagementSheet() - .presentationDetents([.medium, .large]) - } - .sheet(isPresented: $showingMiniPlayerPlaylistSheet, onDismiss: { - appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented = false - }) { - if let video = appEnvironment.playerService.state.currentVideo { - PlaylistSelectorSheet(video: video) - .presentationDetents([.medium, .large]) - } - } - } - - private func presentExpandedPlayer() { - let shouldAnimate = !appEnvironment.navigationCoordinator.skipNextPlayerExpandAnimation - appEnvironment.navigationCoordinator.skipNextPlayerExpandAnimation = false - ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: shouldAnimate) - } -} #endif // MARK: - Preview diff --git a/Yattee/Core/NavigationCommands.swift b/Yattee/Core/NavigationCommands.swift index ebb7e6b3..735c2730 100644 --- a/Yattee/Core/NavigationCommands.swift +++ b/Yattee/Core/NavigationCommands.swift @@ -60,7 +60,7 @@ struct NavigationCommands: Commands { private var homeButton: some View { Button { - navigationCoordinator.selectedTab = .home + navigationCoordinator.router.select(.home) } label: { Text(String(localized: "menu.navigation.home")) } @@ -69,7 +69,7 @@ struct NavigationCommands: Commands { private var subscriptionsButton: some View { Button { - navigationCoordinator.selectedTab = .subscriptions + navigationCoordinator.router.select(.subscriptionsFeed) } label: { Text(String(localized: "menu.navigation.subscriptions")) } @@ -78,7 +78,7 @@ struct NavigationCommands: Commands { private var searchButton: some View { Button { - navigationCoordinator.selectedTab = .search + navigationCoordinator.router.select(.search) } label: { Text(String(localized: "menu.navigation.search")) } @@ -87,7 +87,7 @@ struct NavigationCommands: Commands { private var bookmarksButton: some View { Button { - navigationCoordinator.selectedSidebarItem = .bookmarks + navigationCoordinator.router.select(.bookmarks) } label: { Text(String(localized: "menu.navigation.bookmarks")) } @@ -96,7 +96,7 @@ struct NavigationCommands: Commands { private var historyButton: some View { Button { - navigationCoordinator.selectedSidebarItem = .history + navigationCoordinator.router.select(.history) } label: { Text(String(localized: "menu.navigation.history")) } @@ -105,7 +105,7 @@ struct NavigationCommands: Commands { private var downloadsButton: some View { Button { - navigationCoordinator.selectedSidebarItem = .downloads + navigationCoordinator.router.select(.downloads) } label: { Text(String(localized: "menu.navigation.downloads")) } @@ -114,7 +114,7 @@ struct NavigationCommands: Commands { private var channelsButton: some View { Button { - navigationCoordinator.selectedSidebarItem = .manageChannels + navigationCoordinator.router.select(.manageChannels) } label: { Text(String(localized: "menu.navigation.channels")) } @@ -123,7 +123,7 @@ struct NavigationCommands: Commands { private var sourcesButton: some View { Button { - navigationCoordinator.selectedSidebarItem = .sources + navigationCoordinator.router.select(.sources) } label: { Text(String(localized: "menu.navigation.sources")) } @@ -132,7 +132,7 @@ struct NavigationCommands: Commands { private var settingsButton: some View { Button { - navigationCoordinator.selectedSidebarItem = .settings + navigationCoordinator.router.select(.settings) } label: { Text(String(localized: "menu.navigation.settings")) } diff --git a/Yattee/Models/Navigation/AppTab.swift b/Yattee/Models/Navigation/AppTab.swift deleted file mode 100644 index 23a7acae..00000000 --- a/Yattee/Models/Navigation/AppTab.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// AppTab.swift -// Yattee -// -// Main app tab definitions. -// - -import Foundation - -enum AppTab: String, CaseIterable, Identifiable { - case home - case subscriptions - case search - #if os(tvOS) - case settings - #endif - - var id: String { rawValue } - - var title: String { - switch self { - case .home: return String(localized: "tabs.home") - case .subscriptions: return String(localized: "tabs.subscriptions") - case .search: return String(localized: "tabs.search") - #if os(tvOS) - case .settings: return String(localized: "tabs.settings") - #endif - } - } - - var systemImage: String { - switch self { - case .home: return "house.fill" - case .subscriptions: return "play.square.stack.fill" - case .search: return "magnifyingglass" - #if os(tvOS) - case .settings: return "gearshape" - #endif - } - } - - /// SidebarItem equivalent for UnifiedTabView navigation. - var sidebarItem: SidebarItem { - switch self { - case .home: return .home - case .subscriptions: return .subscriptionsFeed - case .search: return .search - #if os(tvOS) - case .settings: return .settings - #endif - } - } -} diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index a856186d..2bfd099d 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -7,59 +7,31 @@ import SwiftUI -/// Sheet destinations for modal presentation. -enum SheetDestination: Identifiable { - case settings - case addToPlaylist(Video) - case downloadOptions(Video) - case newPlaylist - - var id: String { - switch self { - case .settings: return "settings" - case .addToPlaylist(let video): return "addToPlaylist-\(video.id.id)" - case .downloadOptions(let video): return "downloadOptions-\(video.id.id)" - case .newPlaylist: return "newPlaylist" - } - } -} - /// Centralized navigation coordinator for the app. @Observable @MainActor final class NavigationCoordinator { - /// Navigation path for NavigationStack. - var path = NavigationPath() + /// Selected section and per-section stacks, shared by every layout. + let router = NavigationRouter() - /// Currently selected tab. - var selectedTab: AppTab = .home { - didSet { - updateHandoffForCurrentTab() + init() { + router.onSelectionChange = { [weak self] _ in + self?.updateHandoffForCurrentTab() } } - /// Directly selected sidebar item (for extended navigation commands). - /// When set, UnifiedTabView syncs this to its selection state. - var selectedSidebarItem: SidebarItem? - /// Currently selected home sub-tab. var selectedHomeTab: HomeTab = .playlists { didSet { - if selectedTab == .home { + if router.selection == .home { updateHandoffForCurrentTab() } } } - /// Currently presented sheet. - var presentedSheet: SheetDestination? - /// Trigger to dismiss settings sheet (incremented when dismiss is needed). var dismissSettingsTrigger = 0 - /// Mini player video (if playing). - var nowPlaying: Video? - /// Whether the mini player is expanded to full screen. var isPlayerExpanded = false { didSet { @@ -244,9 +216,6 @@ final class NavigationCoordinator { } } - /// Pending navigation destination (used when navigating from sheets/modals). - var pendingNavigation: NavigationDestination? - /// Reference to HandoffManager for updating activities on navigation. private weak var handoffManager: HandoffManager? @@ -267,35 +236,14 @@ final class NavigationCoordinator { // MARK: - Navigation Actions - /// Navigate to a destination. + /// Navigate to a destination by pushing it onto the selected section's stack. func navigate(to destination: NavigationDestination) { - pendingNavigation = destination + router.push(destination) handoffManager?.updateActivity(for: destination) } - /// Clear pending navigation after it's been handled. - func clearPendingNavigation() { - pendingNavigation = nil - } - - /// Pop to the root of the current navigation stack. - func popToRoot() { - path.removeLast(path.count) - } - - /// Pop one level back in the navigation stack. - func pop() { - guard !path.isEmpty else { return } - path.removeLast() - } - // MARK: - Sheet Presentation - /// Dismiss the current sheet. - func dismissSheet() { - presentedSheet = nil - } - /// Dismiss the settings sheet from anywhere in the app. func dismissSettings() { dismissSettingsTrigger += 1 @@ -334,21 +282,11 @@ final class NavigationCoordinator { } } - // MARK: - URL Handling - - /// Handle an incoming URL. - func handle(url: URL) { - let router = URLRouter() - if let destination = router.route(url) { - navigate(to: destination) - } - } - // MARK: - Convenience Navigation /// Navigate to the subscriptions tab/feed. func navigateToSubscriptions() { - selectedTab = .subscriptions + router.select(.subscriptionsFeed) } /// Navigate to a video by its ID string. @@ -385,13 +323,11 @@ final class NavigationCoordinator { // MARK: - Handoff Updates - /// Updates Handoff activity based on current tab selection. + /// Updates Handoff activity based on the selected section. private func updateHandoffForCurrentTab() { let destination: NavigationDestination? - switch selectedTab { - case .subscriptions: - destination = .subscriptionsFeed + switch router.selection { case .home: switch selectedHomeTab { case .playlists: @@ -404,10 +340,10 @@ final class NavigationCoordinator { case .search: // Search updates handoff when a search is performed, not on tab selection destination = nil - #if os(tvOS) - case .settings: + case .mediaSource, .instance, .sources: destination = nil - #endif + default: + destination = router.selection.navigationDestination() } if let destination { diff --git a/Yattee/Services/Navigation/NavigationRouter.swift b/Yattee/Services/Navigation/NavigationRouter.swift new file mode 100644 index 00000000..d63f55a7 --- /dev/null +++ b/Yattee/Services/Navigation/NavigationRouter.swift @@ -0,0 +1,146 @@ +// +// NavigationRouter.swift +// Yattee +// +// Layout-independent navigation state: which section is selected and what is +// pushed in each section's stack. The tab bar and the sidebar layouts both +// render this state, so swapping between them (iPad Stage Manager resize, +// iPhone Duo fold/unfold) keeps the user where they were. +// + +import SwiftUI + +@Observable +@MainActor +final class NavigationRouter { + /// The selected section. Both the tab bar and the sidebar bind to this. + var selection: SidebarItem = .home { + didSet { + guard selection != oldValue else { return } + onSelectionChange?(selection) + } + } + + /// Pushed destinations per section, keyed by `SidebarItem.id` so that a + /// renamed channel or playlist keeps its stack. + private(set) var stacks: [String: [NavigationDestination]] = [:] + + /// Search field text, shared so it survives a layout swap. + var searchText = "" + + /// Sidebar channels, playlists and sources. Owned here rather than by the + /// sidebar layout so it is created and loaded once. + let sidebarManager = SidebarManager() + + @ObservationIgnored private var hasAppliedStartupTab = false + @ObservationIgnored private var isSidebarManagerConfigured = false + + /// Called after every selection change (Handoff updates). + @ObservationIgnored var onSelectionChange: ((SidebarItem) -> Void)? + + // MARK: - Stacks + + func path(for item: SidebarItem) -> Binding<[NavigationDestination]> { + let key = item.id + return Binding( + get: { self.stacks[key] ?? [] }, + set: { self.stacks[key] = $0 } + ) + } + + func stack(for item: SidebarItem) -> [NavigationDestination] { + stacks[item.id] ?? [] + } + + /// Pushes a destination onto the selected section's stack. + func push(_ destination: NavigationDestination) { + stacks[selection.id, default: []].append(destination) + } + + func popToRoot(_ item: SidebarItem? = nil) { + stacks[(item ?? selection).id] = [] + } + + func select(_ item: SidebarItem) { + selection = item + } + + // MARK: - Startup + + /// Applies the configured startup section once per app run. Later layout + /// swaps keep the current selection instead of jumping back to it. + /// + /// A destination pushed before the first layout appeared (a cold-launch + /// deep link or Handoff) wins over the startup preference. + func applyStartupTabIfNeeded(_ startupTab: SidebarMainItem) { + guard !hasAppliedStartupTab else { return } + hasAppliedStartupTab = true + guard stack(for: selection).isEmpty else { return } + selection = startupTab.sidebarItem + } + + func configureSidebarManagerIfNeeded(_ appEnvironment: AppEnvironment) { + guard !isSidebarManagerConfigured else { return } + isSidebarManagerConfigured = true + sidebarManager.configure( + dataManager: appEnvironment.dataManager, + settingsManager: appEnvironment.settingsManager, + mediaSourcesManager: appEnvironment.mediaSourcesManager, + instancesManager: appEnvironment.instancesManager, + accountsManager: appEnvironment.accountsManager, + subscriptionService: appEnvironment.subscriptionService, + remotePlaylistsCache: appEnvironment.remotePlaylistsCache, + contextValidator: appEnvironment.dataContextValidator + ) + } + + // MARK: - Layout Mapping + + /// Makes the selection one the current layout can show. + /// + /// When the selected section has no tab or row of its own in this layout + /// (a sidebar channel shown in the tab bar, a tab hidden by customization), + /// the user lands on the closest section that can host it, with the + /// section's own screen pushed first and its stack kept after it. + /// + /// - Parameters: + /// - isAvailable: whether the layout shows `item` as a tab or row. + /// - instance: resolves an instance ID for `.instance` items. + func ensureSelectionIsAvailable( + isAvailable: (SidebarItem) -> Bool, + instance: (UUID) -> Instance? = { _ in nil } + ) { + let current = selection + guard !isAvailable(current) else { return } + + let host: SidebarItem + let root: NavigationDestination? + switch current { + case .channel(let channelID, _, let source): + host = isAvailable(.subscriptionsFeed) ? .subscriptionsFeed : .home + root = .channel(channelID, source) + case .playlist, .remotePlaylist: + host = isAvailable(.playlistsList) ? .playlistsList : .home + root = current.navigationDestination() + case .mediaSource(let id, _, _): + host = isAvailable(.sources) ? .sources : .home + root = .mediaSource(id) + case .instance(let id, _, _): + host = isAvailable(.sources) ? .sources : .home + root = instance(id).map { .instanceBrowse($0) } + case .sources: + host = .home + root = .mediaSources + default: + host = .home + root = current.navigationDestination() + } + + let carried = stack(for: current) + if let root { + stacks[host.id] = [root] + carried + } + stacks[current.id] = [] + selection = host + } +} diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index bd883977..2314dc1a 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -250,8 +250,8 @@ struct HomeView: View { loadData() } } - .onChange(of: appEnvironment?.navigationCoordinator.selectedTab) { _, newTab in - if newTab == .home { + .onChange(of: appEnvironment?.navigationCoordinator.router.selection) { _, newSelection in + if newSelection == .home { loadData() } } @@ -388,7 +388,7 @@ struct HomeView: View { } } Button { - appEnvironment?.navigationCoordinator.selectedSidebarItem = .sources + appEnvironment?.navigationCoordinator.router.select(.sources) } label: { Label(String(localized: "home.tvos.empty.openSources"), systemImage: "externaldrive.connected.to.line.below") .padding(.horizontal, 16) diff --git a/Yattee/Views/Navigation/CompactTabView.swift b/Yattee/Views/Navigation/CompactTabView.swift index cd828189..1cc08386 100644 --- a/Yattee/Views/Navigation/CompactTabView.swift +++ b/Yattee/Views/Navigation/CompactTabView.swift @@ -12,28 +12,7 @@ import SwiftUI struct CompactTabView: View { @Environment(\.appEnvironment) private var appEnvironment - // Navigation paths for fixed tabs - @State private var homePath = NavigationPath() - @State private var searchPath = NavigationPath() - - // Navigation paths for dynamic tabs - @State private var subscriptionsPath = NavigationPath() - @State private var channelsPath = NavigationPath() - @State private var bookmarksPath = NavigationPath() - @State private var playlistsPath = NavigationPath() - @State private var historyPath = NavigationPath() - @State private var downloadsPath = NavigationPath() - @State private var sourcesPath = NavigationPath() - @State private var settingsPath = NavigationPath() - @State private var continueWatchingPath = NavigationPath() - - // Tab selection - using String to support both fixed and dynamic tabs - // Initial value is a placeholder; actual startup tab is applied in onAppear - @State private var selectedTab: String = "home" - @State private var hasAppliedStartupTab = false - - // Search text state (iOS 26+ TabView .searchable integration) - @State private var searchText = "" + let router: NavigationRouter // Zoom transition namespace (local to this tab view) @Namespace private var zoomTransition @@ -42,10 +21,6 @@ struct CompactTabView: View { appEnvironment?.settingsManager } - private var navigationCoordinator: NavigationCoordinator? { - appEnvironment?.navigationCoordinator - } - /// Returns the visible custom tabs from settings private var visibleTabItems: [TabBarItem] { settingsManager?.visibleTabBarItems() ?? [] @@ -62,10 +37,12 @@ struct CompactTabView: View { } var body: some View { - TabView(selection: $selectedTab) { + @Bindable var router = router + + TabView(selection: $router.selection) { // Fixed: Home (first) - Tab(value: "home") { - NavigationStack(path: $homePath) { + Tab(value: SidebarItem.home) { + NavigationStack(path: router.path(for: .home)) { HomeView() .withNavigationDestinations() } @@ -76,17 +53,20 @@ struct CompactTabView: View { // Dynamic tabs from settings (in the middle, SwiftUI auto-collapses overflow into More) ForEach(visibleTabItems) { item in - Tab(value: item.rawValue) { - tabContent(for: item) + Tab(value: Self.sidebarItem(for: item)) { + NavigationStack(path: router.path(for: Self.sidebarItem(for: item))) { + tabContent(for: item) + .withNavigationDestinations() + } } label: { Label(item.localizedTitle, systemImage: item.icon) } } // Fixed: Search (last) - with role: .search - Tab(value: "search", role: .search) { - NavigationStack(path: $searchPath) { - SearchView(searchText: $searchText) + Tab(value: SidebarItem.search, role: .search) { + NavigationStack(path: router.path(for: .search)) { + SearchView(searchText: $router.searchText) .withNavigationDestinations() } } label: { @@ -97,156 +77,32 @@ struct CompactTabView: View { .zoomTransitionNamespace(zoomTransition) .zoomTransitionsEnabled(zoomTransitionsEnabled) .iOS26TabFeatures(shouldShowAccessory: shouldShowAccessory, settingsManager: settingsManager) - .onChange(of: navigationCoordinator?.pendingNavigation) { _, newValue in - handlePendingNavigation(newValue) - } - .onChange(of: selectedTab) { _, newTab in - updateHandoffForTab(newTab) - syncTabToCoordinator(newTab) - } - .onChange(of: navigationCoordinator?.selectedTab) { _, newTab in - syncTabFromCoordinator(newTab) - } .onAppear { - applyStartupTabIfNeeded() + router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForTabBar() ?? .home) + ensureSelectionIsAvailable() + } + .onChange(of: router.selection) { _, _ in + ensureSelectionIsAvailable() + } + .onChange(of: visibleTabItems) { _, _ in + ensureSelectionIsAvailable() } } - // MARK: - Startup Tab + // MARK: - Layout Mapping - /// Applies the configured startup tab on first appearance. - private func applyStartupTabIfNeeded() { - guard !hasAppliedStartupTab else { return } - hasAppliedStartupTab = true - - let startupTab = settingsManager?.effectiveStartupTabForTabBar() ?? .home - selectedTab = startupTab.compactTabValue + private static func sidebarItem(for item: TabBarItem) -> SidebarItem { + SidebarMainItem(tabBarItem: item)?.sidebarItem ?? .home } - // MARK: - Handoff - - /// Updates Handoff activity based on the selected tab. - private func updateHandoffForTab(_ tab: String) { - let destination: NavigationDestination? - - switch tab { - case "home": - // Home tab - use playlists as default (matches HomeView's primary content) - destination = .playlists - case "search": - // Search updates handoff when a search is performed - destination = nil - case TabBarItem.subscriptions.rawValue: - destination = .subscriptionsFeed - case TabBarItem.channels.rawValue: - destination = .manageChannels - case TabBarItem.bookmarks.rawValue: - destination = .bookmarks - case TabBarItem.playlists.rawValue: - destination = .playlists - case TabBarItem.history.rawValue: - destination = .history - case TabBarItem.downloads.rawValue: - destination = .downloads - case TabBarItem.sources.rawValue: - destination = nil // No handoff for sources - case TabBarItem.settings.rawValue: - destination = nil // No handoff for settings - default: - destination = nil - } - - if let destination { - appEnvironment?.handoffManager.updateActivity(for: destination) - } - } - - // MARK: - Tab Sync with NavigationCoordinator - - /// Syncs NavigationCoordinator's selectedTab to local state (coordinator → local). - /// Called when NavigationCoordinator.selectedTab changes (e.g., from notification tap). - private func syncTabFromCoordinator(_ appTab: AppTab?) { - guard let appTab else { return } - - switch appTab { - case .home: - if selectedTab != "home" { - selectedTab = "home" - } - case .subscriptions: - if visibleTabItems.contains(.subscriptions) { - // Subscriptions tab is visible - switch to it - let tabValue = TabBarItem.subscriptions.rawValue - if selectedTab != tabValue { - selectedTab = tabValue - } - } else { - // Subscriptions tab not visible - push subscriptions view onto current stack - pushSubscriptionsOnCurrentStack() - } - case .search: - if selectedTab != "search" { - selectedTab = "search" - } - #if os(tvOS) - case .settings: - break - #endif - } - } - - /// Syncs local selectedTab to NavigationCoordinator (local → coordinator). - /// Called when user manually switches tabs. - private func syncTabToCoordinator(_ tab: String) { - guard let coordinator = navigationCoordinator else { return } - - let appTab: AppTab - switch tab { - case "home": - appTab = .home - case "search": - appTab = .search - case TabBarItem.subscriptions.rawValue: - appTab = .subscriptions - default: - // Other tabs (channels, bookmarks, downloads, etc.) don't have AppTab equivalents - // Don't update coordinator - just return to avoid feedback loop - return - } - - if coordinator.selectedTab != appTab { - coordinator.selectedTab = appTab - } - } - - /// Pushes the subscriptions feed onto the current tab's navigation stack. - /// Used when subscriptions tab is not visible but we need to navigate to subscriptions. - private func pushSubscriptionsOnCurrentStack() { - let destination = NavigationDestination.subscriptionsFeed - switch selectedTab { - case "home": - homePath.append(destination) - case "search": - searchPath.append(destination) - case TabBarItem.channels.rawValue: - channelsPath.append(destination) - case TabBarItem.bookmarks.rawValue: - bookmarksPath.append(destination) - case TabBarItem.playlists.rawValue: - playlistsPath.append(destination) - case TabBarItem.history.rawValue: - historyPath.append(destination) - case TabBarItem.downloads.rawValue: - downloadsPath.append(destination) - case TabBarItem.sources.rawValue: - sourcesPath.append(destination) - case TabBarItem.settings.rawValue: - settingsPath.append(destination) - case TabBarItem.continueWatching.rawValue: - continueWatchingPath.append(destination) - default: - homePath.append(destination) - } + /// Sidebar-only selections (channels, playlists, sources) and tabs hidden + /// by customization are remapped onto a tab that can host them. + private func ensureSelectionIsAvailable() { + let available = Set([SidebarItem.home, .search] + visibleTabItems.map(Self.sidebarItem(for:))) + router.ensureSelectionIsAvailable( + isAvailable: { available.contains($0) }, + instance: { id in appEnvironment?.instancesManager.enabledInstances.first { $0.id == id } } + ) } // MARK: - Tab Content @@ -255,95 +111,31 @@ struct CompactTabView: View { private func tabContent(for item: TabBarItem) -> some View { switch item { case .subscriptions: - NavigationStack(path: $subscriptionsPath) { - SubscriptionsView() - .withNavigationDestinations() - } + SubscriptionsView() case .channels: - NavigationStack(path: $channelsPath) { - ManageChannelsView() - .withNavigationDestinations() - } + ManageChannelsView() case .bookmarks: - NavigationStack(path: $bookmarksPath) { - BookmarksListView() - .withNavigationDestinations() - } + BookmarksListView() case .playlists: - NavigationStack(path: $playlistsPath) { - PlaylistsListView() - .withNavigationDestinations() - } + PlaylistsListView() case .history: - NavigationStack(path: $historyPath) { - HistoryListView() - .withNavigationDestinations() - } + HistoryListView() case .downloads: - NavigationStack(path: $downloadsPath) { - DownloadsView() - .withNavigationDestinations() - } + DownloadsView() case .sources: - NavigationStack(path: $sourcesPath) { - MediaSourcesView() - .withNavigationDestinations() - } + MediaSourcesView() case .settings: - NavigationStack(path: $settingsPath) { - SettingsView(showCloseButton: false) - .withNavigationDestinations() - } + SettingsView(showCloseButton: false) case .continueWatching: - NavigationStack(path: $continueWatchingPath) { - ContinueWatchingView() - .withNavigationDestinations() - } + ContinueWatchingView() } } - - // MARK: - Navigation Handling - - private func handlePendingNavigation(_ destination: NavigationDestination?) { - guard let destination else { return } - - // Append to the current tab's path - switch selectedTab { - case "home": - homePath.append(destination) - case "search": - searchPath.append(destination) - case TabBarItem.subscriptions.rawValue: - subscriptionsPath.append(destination) - case TabBarItem.channels.rawValue: - channelsPath.append(destination) - case TabBarItem.bookmarks.rawValue: - bookmarksPath.append(destination) - case TabBarItem.playlists.rawValue: - playlistsPath.append(destination) - case TabBarItem.history.rawValue: - historyPath.append(destination) - case TabBarItem.downloads.rawValue: - downloadsPath.append(destination) - case TabBarItem.sources.rawValue: - sourcesPath.append(destination) - case TabBarItem.settings.rawValue: - settingsPath.append(destination) - case TabBarItem.continueWatching.rawValue: - continueWatchingPath.append(destination) - default: - // Fallback to home - homePath.append(destination) - } - - navigationCoordinator?.clearPendingNavigation() - } } // MARK: - Preview #Preview { - CompactTabView() + CompactTabView(router: NavigationRouter()) .appEnvironment(.preview) } #endif diff --git a/Yattee/Views/Navigation/UnifiedTabView.swift b/Yattee/Views/Navigation/UnifiedTabView.swift index 9800736d..90e59e3a 100644 --- a/Yattee/Views/Navigation/UnifiedTabView.swift +++ b/Yattee/Views/Navigation/UnifiedTabView.swift @@ -16,34 +16,7 @@ struct UnifiedTabView: View { @Environment(\.appEnvironment) private var appEnvironment @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Binding var selectedTab: AppTab - - // Sidebar manager for dynamic content - @State private var sidebarManager = SidebarManager() - - // Navigation paths - @State private var homePath = NavigationPath() - @State private var searchPath = NavigationPath() - @State private var channelPaths: [String: NavigationPath] = [:] - @State private var playlistPaths: [UUID: NavigationPath] = [:] - @State private var remotePlaylistPaths: [String: NavigationPath] = [:] - @State private var mediaSourcePaths: [UUID: NavigationPath] = [:] - @State private var instancePaths: [UUID: NavigationPath] = [:] - @State private var bookmarksPath = NavigationPath() - @State private var historyPath = NavigationPath() - @State private var downloadsPath = NavigationPath() - @State private var subscriptionsFeedPath = NavigationPath() - @State private var manageChannelsPath = NavigationPath() - @State private var playlistsListPath = NavigationPath() - @State private var sourcesPath = NavigationPath() - @State private var settingsPath = NavigationPath() - @State private var openURLPath = NavigationPath() - @State private var remoteControlPath = NavigationPath() - @State private var continueWatchingPath = NavigationPath() - - // Current selection - initial value is a placeholder; actual startup tab is applied in onAppear - @State private var selection: SidebarItem = .home - @State private var hasAppliedStartupTab = false + let router: NavigationRouter // Zoom transition namespace (local to this tab view) @Namespace private var zoomTransition @@ -63,7 +36,9 @@ struct UnifiedTabView: View { } var body: some View { - TabView(selection: $selection) { + @Bindable var router = router + + TabView(selection: $router.selection) { mainTabs sidebarSections } @@ -88,28 +63,12 @@ struct UnifiedTabView: View { .zoomTransitionsEnabled(zoomTransitionsEnabled) .onAppear { configureSidebarManager() - applyStartupTabIfNeeded() + router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home) + ensureSelectionIsAvailable(visibleMainItems: visibleMainItems) } - .onChange(of: navigationCoordinator?.pendingNavigation) { _, newValue in - handlePendingNavigation(newValue) + .onChange(of: router.selection) { _, _ in + ensureSelectionIsAvailable(visibleMainItems: visibleMainItems) } - .onChange(of: selectedTab) { _, newTab in - selection = newTab.sidebarItem - } - .onChange(of: navigationCoordinator?.selectedSidebarItem) { _, newItem in - guard let item = newItem else { return } - selection = item - navigationCoordinator?.selectedSidebarItem = nil - } - } - - /// Applies the configured startup tab on first appearance. - private func applyStartupTabIfNeeded() { - guard !hasAppliedStartupTab else { return } - hasAppliedStartupTab = true - - let startupTab = settingsManager?.effectiveStartupTabForSidebar() ?? .home - selection = startupTab.sidebarItem } // MARK: - Visible Main Items @@ -132,8 +91,8 @@ struct UnifiedTabView: View { switch item { case .search: Tab(value: SidebarItem.search, role: .search) { - NavigationStack(path: $searchPath) { - SearchView() + NavigationStack(path: router.path(for: .search)) { + SearchView(searchText: Bindable(router).searchText) .withNavigationDestinations() } } label: { @@ -142,7 +101,7 @@ struct UnifiedTabView: View { case .home: Tab(value: SidebarItem.home) { - NavigationStack(path: $homePath) { + NavigationStack(path: router.path(for: .home)) { HomeView() .withNavigationDestinations() } @@ -152,7 +111,7 @@ struct UnifiedTabView: View { case .subscriptions: Tab(value: SidebarItem.subscriptionsFeed) { - NavigationStack(path: $subscriptionsFeedPath) { + NavigationStack(path: router.path(for: .subscriptionsFeed)) { SubscriptionsView() .withNavigationDestinations() } @@ -162,7 +121,7 @@ struct UnifiedTabView: View { case .bookmarks: Tab(value: SidebarItem.bookmarks) { - NavigationStack(path: $bookmarksPath) { + NavigationStack(path: router.path(for: .bookmarks)) { BookmarksListView() .withNavigationDestinations() } @@ -172,7 +131,7 @@ struct UnifiedTabView: View { case .history: Tab(value: SidebarItem.history) { - NavigationStack(path: $historyPath) { + NavigationStack(path: router.path(for: .history)) { HistoryListView() .withNavigationDestinations() } @@ -182,7 +141,7 @@ struct UnifiedTabView: View { case .downloads: Tab(value: SidebarItem.downloads) { - NavigationStack(path: $downloadsPath) { + NavigationStack(path: router.path(for: .downloads)) { DownloadsView() .withNavigationDestinations() } @@ -192,7 +151,7 @@ struct UnifiedTabView: View { case .channels: Tab(value: SidebarItem.manageChannels) { - NavigationStack(path: $manageChannelsPath) { + NavigationStack(path: router.path(for: .manageChannels)) { ManageChannelsView() .withNavigationDestinations() } @@ -202,7 +161,7 @@ struct UnifiedTabView: View { case .playlists: Tab(value: SidebarItem.playlistsList) { - NavigationStack(path: $playlistsListPath) { + NavigationStack(path: router.path(for: .playlistsList)) { PlaylistsListView() .withNavigationDestinations() } @@ -212,7 +171,7 @@ struct UnifiedTabView: View { case .sources: Tab(value: SidebarItem.sources) { - NavigationStack(path: $sourcesPath) { + NavigationStack(path: router.path(for: .sources)) { MediaSourcesView() .withNavigationDestinations() } @@ -222,7 +181,7 @@ struct UnifiedTabView: View { case .settings: Tab(value: SidebarItem.settings) { - NavigationStack(path: $settingsPath) { + NavigationStack(path: router.path(for: .settings)) { SettingsView(showCloseButton: false) .withNavigationDestinations() } @@ -232,7 +191,7 @@ struct UnifiedTabView: View { case .openURL: Tab(value: SidebarItem.openURL) { - NavigationStack(path: $openURLPath) { + NavigationStack(path: router.path(for: .openURL)) { OpenLinkView() .withNavigationDestinations() } @@ -242,7 +201,7 @@ struct UnifiedTabView: View { case .remoteControl: Tab(value: SidebarItem.remoteControl) { - NavigationStack(path: $remoteControlPath) { + NavigationStack(path: router.path(for: .remoteControl)) { RemoteControlContentView(navigationStyle: .link) .navigationTitle(String(localized: "remoteControl.title")) .withNavigationDestinations() @@ -253,7 +212,7 @@ struct UnifiedTabView: View { case .continueWatching: Tab(value: SidebarItem.continueWatching) { - NavigationStack(path: $continueWatchingPath) { + NavigationStack(path: router.path(for: .continueWatching)) { ContinueWatchingView() .withNavigationDestinations() } @@ -364,7 +323,7 @@ extension MiniPlayerMinimizeBehavior { } #Preview("Unified Tab View - iOS") { - UnifiedTabView(selectedTab: .constant(.home)) + UnifiedTabView(router: NavigationRouter()) .appEnvironment(.preview) } #endif @@ -375,34 +334,7 @@ extension MiniPlayerMinimizeBehavior { struct UnifiedTabView: View { @Environment(\.appEnvironment) private var appEnvironment - @Binding var selectedTab: AppTab - - // Sidebar manager for dynamic content - @State private var sidebarManager = SidebarManager() - - // Navigation paths - @State private var homePath = NavigationPath() - @State private var searchPath = NavigationPath() - @State private var channelPaths: [String: NavigationPath] = [:] - @State private var playlistPaths: [UUID: NavigationPath] = [:] - @State private var remotePlaylistPaths: [String: NavigationPath] = [:] - @State private var mediaSourcePaths: [UUID: NavigationPath] = [:] - @State private var instancePaths: [UUID: NavigationPath] = [:] - @State private var bookmarksPath = NavigationPath() - @State private var historyPath = NavigationPath() - @State private var downloadsPath = NavigationPath() - @State private var subscriptionsFeedPath = NavigationPath() - @State private var manageChannelsPath = NavigationPath() - @State private var playlistsListPath = NavigationPath() - @State private var sourcesPath = NavigationPath() - @State private var settingsPath = NavigationPath() - @State private var openURLPath = NavigationPath() - @State private var remoteControlPath = NavigationPath() - @State private var continueWatchingPath = NavigationPath() - - // Current selection - initial value is a placeholder; actual startup tab is applied in onAppear - @State private var selection: SidebarItem = .home - @State private var hasAppliedStartupTab = false + let router: NavigationRouter // Zoom transition namespace (local to this tab view) @Namespace private var zoomTransition @@ -421,28 +353,12 @@ struct UnifiedTabView: View { .zoomTransitionNamespace(zoomTransition) .onAppear { configureSidebarManager() - applyStartupTabIfNeeded() + router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home) + ensureSelectionIsAvailable(visibleMainItems: visibleMainItems) } - .onChange(of: navigationCoordinator?.pendingNavigation) { _, newValue in - handlePendingNavigation(newValue) + .onChange(of: router.selection) { _, _ in + ensureSelectionIsAvailable(visibleMainItems: visibleMainItems) } - .onChange(of: selectedTab) { _, newTab in - selection = newTab.sidebarItem - } - .onChange(of: navigationCoordinator?.selectedSidebarItem) { _, newItem in - guard let item = newItem else { return } - selection = item - navigationCoordinator?.selectedSidebarItem = nil - } - } - - /// Applies the configured startup tab on first appearance. - private func applyStartupTabIfNeeded() { - guard !hasAppliedStartupTab else { return } - hasAppliedStartupTab = true - - let startupTab = settingsManager?.effectiveStartupTabForSidebar() ?? .home - selection = startupTab.sidebarItem } // MARK: - Visible Main Items @@ -473,7 +389,9 @@ struct UnifiedTabView: View { } private var sidebarList: some View { - List(selection: $selection) { + @Bindable var router = router + + return List(selection: $router.selection) { ForEach(visibleMainItems) { item in sidebarRow(for: item) } @@ -522,59 +440,60 @@ struct UnifiedTabView: View { @ViewBuilder private var detailForSelection: some View { + let selection = router.selection switch selection { case .home: - NavigationStack(path: $homePath) { + NavigationStack(path: router.path(for: .home)) { HomeView().withNavigationDestinations() } case .search: - NavigationStack(path: $searchPath) { + NavigationStack(path: router.path(for: .search)) { SearchView().withNavigationDestinations() } case .subscriptionsFeed: - NavigationStack(path: $subscriptionsFeedPath) { + NavigationStack(path: router.path(for: .subscriptionsFeed)) { SubscriptionsView().withNavigationDestinations() } case .bookmarks: - NavigationStack(path: $bookmarksPath) { + NavigationStack(path: router.path(for: .bookmarks)) { BookmarksListView().withNavigationDestinations() } case .history: - NavigationStack(path: $historyPath) { + NavigationStack(path: router.path(for: .history)) { HistoryListView().withNavigationDestinations() } case .downloads: - NavigationStack(path: $downloadsPath) { + NavigationStack(path: router.path(for: .downloads)) { DownloadsView().withNavigationDestinations() } case .manageChannels: - NavigationStack(path: $manageChannelsPath) { + NavigationStack(path: router.path(for: .manageChannels)) { ManageChannelsView().withNavigationDestinations() } case .playlistsList: - NavigationStack(path: $playlistsListPath) { + NavigationStack(path: router.path(for: .playlistsList)) { PlaylistsListView().withNavigationDestinations() } case .sources: - NavigationStack(path: $sourcesPath) { + NavigationStack(path: router.path(for: .sources)) { MediaSourcesView().withNavigationDestinations() } case .settings: - NavigationStack(path: $settingsPath) { + NavigationStack(path: router.path(for: .settings)) { SettingsView().withNavigationDestinations() } case .openURL: - NavigationStack(path: $openURLPath) { + NavigationStack(path: router.path(for: .openURL)) { OpenLinkView().withNavigationDestinations() } case .remoteControl: - NavigationStack(path: $remoteControlPath) { + NavigationStack(path: router.path(for: .remoteControl)) { RemoteControlContentView(navigationStyle: .link) .navigationTitle(String(localized: "remoteControl.title")) .withNavigationDestinations() } case .continueWatching: - NavigationStack(path: $continueWatchingPath) { + NavigationStack(path: router.path(for: .continueWatching)) { ContinueWatchingView() .withNavigationDestinations() } @@ -593,7 +512,7 @@ struct UnifiedTabView: View { } #Preview("Unified Tab View - macOS") { - UnifiedTabView(selectedTab: .constant(.home)) + UnifiedTabView(router: NavigationRouter()) .appEnvironment(.preview) } #endif @@ -604,33 +523,7 @@ struct UnifiedTabView: View { struct UnifiedTabView: View { @Environment(\.appEnvironment) private var appEnvironment - @Binding var selectedTab: AppTab - - // Sidebar manager for dynamic content - @State private var sidebarManager = SidebarManager() - - // Navigation paths - @State private var homePath = NavigationPath() - @State private var searchPath = NavigationPath() - @State private var channelPaths: [String: NavigationPath] = [:] - @State private var playlistPaths: [UUID: NavigationPath] = [:] - @State private var remotePlaylistPaths: [String: NavigationPath] = [:] - @State private var instancePaths: [UUID: NavigationPath] = [:] - @State private var mediaSourcePaths: [UUID: NavigationPath] = [:] - @State private var bookmarksPath = NavigationPath() - @State private var historyPath = NavigationPath() - @State private var subscriptionsFeedPath = NavigationPath() - @State private var manageChannelsPath = NavigationPath() - @State private var playlistsListPath = NavigationPath() - @State private var sourcesPath = NavigationPath() - @State private var settingsPath = NavigationPath() - @State private var openURLPath = NavigationPath() - @State private var remoteControlPath = NavigationPath() - @State private var continueWatchingPath = NavigationPath() - - // Current selection - initial value is a placeholder; actual startup tab is applied in onAppear - @State private var selection: SidebarItem = .home - @State private var hasAppliedStartupTab = false + let router: NavigationRouter // Zoom transition namespace (local to this tab view) @Namespace private var zoomTransition @@ -647,7 +540,9 @@ struct UnifiedTabView: View { } var body: some View { - TabView(selection: $selection) { + @Bindable var router = router + + TabView(selection: $router.selection) { mainTabs sidebarSections } @@ -655,18 +550,15 @@ struct UnifiedTabView: View { .zoomTransitionNamespace(zoomTransition) .onAppear { configureSidebarManager() - applyStartupTabIfNeeded() + router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home) + ensureSelectionIsAvailable(visibleMainItems: visibleMainItems) } - .onChange(of: navigationCoordinator?.pendingNavigation) { _, newValue in - handlePendingNavigation(newValue) - } - .onChange(of: navigationCoordinator?.selectedSidebarItem) { _, newItem in - guard let item = newItem else { return } - selection = item - navigationCoordinator?.selectedSidebarItem = nil - } - .onChange(of: selection) { oldValue, _ in - resetPath(for: oldValue) + .onChange(of: router.selection) { oldValue, _ in + // Pop the previously-selected tab's stack to its root so its + // pushed views don't linger after the user picks another item. + // Before the remap, which may write the stack of `oldValue`. + router.popToRoot(oldValue) + ensureSelectionIsAvailable(visibleMainItems: visibleMainItems) // Force any pushed TVSidebarDetailContainer (used by Settings // sub-pages and similar detail screens) to dismiss. Without this, // tvOS's sidebarAdaptable TabView leaves the previously-pushed @@ -675,58 +567,6 @@ struct UnifiedTabView: View { } } - /// Pops the previously-selected tab's NavigationStack to its root so its - /// pushed views don't linger after the user picks another sidebar item. - private func resetPath(for item: SidebarItem) { - switch item { - case .home: - homePath = NavigationPath() - case .search: - searchPath = NavigationPath() - case .subscriptionsFeed: - subscriptionsFeedPath = NavigationPath() - case .bookmarks: - bookmarksPath = NavigationPath() - case .history: - historyPath = NavigationPath() - case .manageChannels: - manageChannelsPath = NavigationPath() - case .playlistsList: - playlistsListPath = NavigationPath() - case .sources: - sourcesPath = NavigationPath() - case .settings: - settingsPath = NavigationPath() - case .openURL: - openURLPath = NavigationPath() - case .remoteControl: - remoteControlPath = NavigationPath() - case .continueWatching: - continueWatchingPath = NavigationPath() - case let .channel(channelID, _, _): - channelPaths[channelID] = NavigationPath() - case let .playlist(id, _): - playlistPaths[id] = NavigationPath() - case .remotePlaylist: - remotePlaylistPaths[item.id] = NavigationPath() - case let .instance(id, _, _): - instancePaths[id] = NavigationPath() - case let .mediaSource(id, _, _): - mediaSourcePaths[id] = NavigationPath() - case .nowPlaying, .downloads: - break - } - } - - /// Applies the configured startup tab on first appearance. - private func applyStartupTabIfNeeded() { - guard !hasAppliedStartupTab else { return } - hasAppliedStartupTab = true - - let startupTab = settingsManager?.effectiveStartupTabForSidebar() ?? .home - selection = startupTab.sidebarItem - } - // MARK: - Visible Main Items private var visibleMainItems: [SidebarMainItem] { @@ -745,7 +585,7 @@ struct UnifiedTabView: View { .onAppear { appEnvironment?.navigationCoordinator.expandPlayer() // Reset selection to prevent immediate re-trigger when player is collapsed - selection = .home + router.select(.home) } } label: { Label("\(currentVideo.title)\n\(currentVideo.author.name)", systemImage: SidebarItem.nowPlaying.systemImage) @@ -762,7 +602,7 @@ struct UnifiedTabView: View { switch item { case .search: Tab(value: SidebarItem.search, role: .search) { - NavigationStack(path: $searchPath) { + NavigationStack(path: router.path(for: .search)) { SearchView() .withNavigationDestinations() } @@ -772,7 +612,7 @@ struct UnifiedTabView: View { case .home: Tab(value: SidebarItem.home) { - NavigationStack(path: $homePath) { + NavigationStack(path: router.path(for: .home)) { HomeView() .withNavigationDestinations() } @@ -782,7 +622,7 @@ struct UnifiedTabView: View { case .subscriptions: Tab(value: SidebarItem.subscriptionsFeed) { - NavigationStack(path: $subscriptionsFeedPath) { + NavigationStack(path: router.path(for: .subscriptionsFeed)) { SubscriptionsView() .withNavigationDestinations() } @@ -792,7 +632,7 @@ struct UnifiedTabView: View { case .bookmarks: Tab(value: SidebarItem.bookmarks) { - NavigationStack(path: $bookmarksPath) { + NavigationStack(path: router.path(for: .bookmarks)) { BookmarksListView() .withNavigationDestinations() } @@ -802,7 +642,7 @@ struct UnifiedTabView: View { case .history: Tab(value: SidebarItem.history) { - NavigationStack(path: $historyPath) { + NavigationStack(path: router.path(for: .history)) { HistoryListView() .withNavigationDestinations() } @@ -819,7 +659,7 @@ struct UnifiedTabView: View { case .channels: Tab(value: SidebarItem.manageChannels) { - NavigationStack(path: $manageChannelsPath) { + NavigationStack(path: router.path(for: .manageChannels)) { ManageChannelsView() .withNavigationDestinations() } @@ -829,7 +669,7 @@ struct UnifiedTabView: View { case .playlists: Tab(value: SidebarItem.playlistsList) { - NavigationStack(path: $playlistsListPath) { + NavigationStack(path: router.path(for: .playlistsList)) { PlaylistsListView() .withNavigationDestinations() } @@ -839,7 +679,7 @@ struct UnifiedTabView: View { case .sources: Tab(value: SidebarItem.sources) { - NavigationStack(path: $sourcesPath) { + NavigationStack(path: router.path(for: .sources)) { MediaSourcesView() .withNavigationDestinations() } @@ -849,7 +689,7 @@ struct UnifiedTabView: View { case .settings: Tab(value: SidebarItem.settings) { - NavigationStack(path: $settingsPath) { + NavigationStack(path: router.path(for: .settings)) { SettingsView() .withNavigationDestinations() } @@ -859,7 +699,7 @@ struct UnifiedTabView: View { case .openURL: Tab(value: SidebarItem.openURL) { - NavigationStack(path: $openURLPath) { + NavigationStack(path: router.path(for: .openURL)) { TVSidebarDetailContainer( systemImage: SidebarItem.openURL.systemImage, title: SidebarItem.openURL.title @@ -874,7 +714,7 @@ struct UnifiedTabView: View { case .remoteControl: Tab(value: SidebarItem.remoteControl) { - NavigationStack(path: $remoteControlPath) { + NavigationStack(path: router.path(for: .remoteControl)) { TVSidebarDetailContainer( systemImage: SidebarItem.remoteControl.systemImage, title: SidebarItem.remoteControl.title @@ -889,7 +729,7 @@ struct UnifiedTabView: View { case .continueWatching: Tab(value: SidebarItem.continueWatching) { - NavigationStack(path: $continueWatchingPath) { + NavigationStack(path: router.path(for: .continueWatching)) { ContinueWatchingView() .withNavigationDestinations() } @@ -945,7 +785,7 @@ struct UnifiedTabView: View { } #Preview("Unified Tab View - tvOS") { - UnifiedTabView(selectedTab: .constant(.home)) + UnifiedTabView(router: NavigationRouter()) .appEnvironment(.preview) } #endif @@ -956,102 +796,33 @@ extension UnifiedTabView { // MARK: - Computed Properties - var navigationCoordinator: NavigationCoordinator? { - appEnvironment?.navigationCoordinator + var sidebarManager: SidebarManager { + router.sidebarManager } // MARK: - Configuration func configureSidebarManager() { guard let appEnvironment else { return } - sidebarManager.configure( - dataManager: appEnvironment.dataManager, - settingsManager: appEnvironment.settingsManager, - mediaSourcesManager: appEnvironment.mediaSourcesManager, - instancesManager: appEnvironment.instancesManager, - accountsManager: appEnvironment.accountsManager, - subscriptionService: appEnvironment.subscriptionService, - remotePlaylistsCache: appEnvironment.remotePlaylistsCache, - contextValidator: appEnvironment.dataContextValidator - ) + router.configureSidebarManagerIfNeeded(appEnvironment) } - // MARK: - Navigation + // MARK: - Layout Mapping - func handlePendingNavigation(_ destination: NavigationDestination?) { - guard let destination else { return } - switch selection { - case .home: - homePath.append(destination) - case .search: - searchPath.append(destination) - case .channel: - channelPaths[selection.id, default: NavigationPath()].append(destination) - case .playlist(let id, _): - playlistPaths[id, default: NavigationPath()].append(destination) - case .remotePlaylist: - remotePlaylistPaths[selection.id, default: NavigationPath()].append(destination) - case .mediaSource(let id, _, _): - mediaSourcePaths[id, default: NavigationPath()].append(destination) - case .instance(let id, _, _): - instancePaths[id, default: NavigationPath()].append(destination) - case .bookmarks: - bookmarksPath.append(destination) - case .history: - historyPath.append(destination) - case .downloads: - #if os(iOS) || os(macOS) - downloadsPath.append(destination) - #endif - case .subscriptionsFeed: - subscriptionsFeedPath.append(destination) - case .manageChannels: - manageChannelsPath.append(destination) - case .playlistsList: - playlistsListPath.append(destination) - case .sources: - sourcesPath.append(destination) - case .settings: - settingsPath.append(destination) - case .nowPlaying: - break // Now Playing is a root tab, not a push destination - case .openURL: - openURLPath.append(destination) - case .remoteControl: - remoteControlPath.append(destination) - case .continueWatching: - continueWatchingPath.append(destination) - } - navigationCoordinator?.clearPendingNavigation() - } - - // MARK: - Path Bindings - - func channelPathBinding(for item: SidebarItem) -> Binding { - Binding( - get: { channelPaths[item.id] ?? NavigationPath() }, - set: { channelPaths[item.id] = $0 } - ) - } - - func playlistPathBinding(for id: UUID) -> Binding { - Binding( - get: { playlistPaths[id] ?? NavigationPath() }, - set: { playlistPaths[id] = $0 } - ) - } - - func mediaSourcePathBinding(for id: UUID) -> Binding { - Binding( - get: { mediaSourcePaths[id] ?? NavigationPath() }, - set: { mediaSourcePaths[id] = $0 } - ) - } - - func instancePathBinding(for id: UUID) -> Binding { - Binding( - get: { instancePaths[id] ?? NavigationPath() }, - set: { instancePaths[id] = $0 } + /// Main items hidden from the sidebar by customization (or selected in the + /// tab bar layout) are remapped onto a row that can host them. + func ensureSelectionIsAvailable(visibleMainItems: [SidebarMainItem]) { + let available = Set(visibleMainItems.map(\.sidebarItem)) + router.ensureSelectionIsAvailable( + isAvailable: { item in + switch item { + case .channel, .playlist, .remotePlaylist, .mediaSource, .instance, .nowPlaying: + return true + default: + return available.contains(item) + } + }, + instance: { id in appEnvironment?.instancesManager.enabledInstances.first { $0.id == id } } ) } @@ -1060,7 +831,7 @@ extension UnifiedTabView { @ViewBuilder func channelContent(for item: SidebarItem) -> some View { if case .channel(let channelID, _, let source) = item { - NavigationStack(path: channelPathBinding(for: item)) { + NavigationStack(path: router.path(for: item)) { ChannelView(channelID: channelID, source: source) .withNavigationDestinations() } @@ -1089,13 +860,13 @@ extension UnifiedTabView { func playlistContent(for item: SidebarItem) -> some View { switch item { case .playlist(let id, _): - NavigationStack(path: playlistPathBinding(for: id)) { + NavigationStack(path: router.path(for: item)) { UnifiedPlaylistDetailView(source: .local(id)) .withNavigationDestinations() } .id(item.id) case .remotePlaylist(let id, let title, let accountID): - NavigationStack(path: remotePlaylistPathBinding(for: item)) { + NavigationStack(path: router.path(for: item)) { // No instance passed: the detail view resolves it from the // account, same as SidebarItem.navigationDestination(). UnifiedPlaylistDetailView(source: .remote(id, instance: nil, title: title, accountID: accountID)) @@ -1107,13 +878,6 @@ extension UnifiedTabView { } } - func remotePlaylistPathBinding(for item: SidebarItem) -> Binding { - Binding( - get: { remotePlaylistPaths[item.id] ?? NavigationPath() }, - set: { remotePlaylistPaths[item.id] = $0 } - ) - } - @ViewBuilder func playlistLabel(for item: SidebarItem) -> some View { if item.isPlaylist { @@ -1131,7 +895,7 @@ extension UnifiedTabView { func mediaSourceContent(for item: SidebarItem) -> some View { if case .mediaSource(let id, _, _) = item, let source = appEnvironment?.mediaSourcesManager.source(byID: id) { - NavigationStack(path: mediaSourcePathBinding(for: id)) { + NavigationStack(path: router.path(for: item)) { MediaBrowserView(source: source, path: "/") .withNavigationDestinations() } @@ -1145,7 +909,7 @@ extension UnifiedTabView { func instanceContent(for item: SidebarItem) -> some View { if case .instance(let id, _, _) = item, let instance = appEnvironment?.instancesManager.enabledInstances.first(where: { $0.id == id }) { - NavigationStack(path: instancePathBinding(for: id)) { + NavigationStack(path: router.path(for: item)) { InstanceBrowseView(instance: instance) .withNavigationDestinations() } diff --git a/YatteeTests/NavigationTests.swift b/YatteeTests/NavigationTests.swift index 85e3edae..054ae909 100644 --- a/YatteeTests/NavigationTests.swift +++ b/YatteeTests/NavigationTests.swift @@ -603,22 +603,21 @@ struct NavigationCoordinatorTests { func initialState() { let coordinator = NavigationCoordinator() - #expect(coordinator.selectedTab == .home) - #expect(coordinator.path.isEmpty) - #expect(coordinator.presentedSheet == nil) + #expect(coordinator.router.selection == .home) + #expect(coordinator.router.stack(for: .home).isEmpty) } - @Test("Navigate to destination sets pending navigation") + @Test("Navigate pushes onto the selected section's stack") func navigateToDestination() { let coordinator = NavigationCoordinator() let destination = NavigationDestination.video(.id(.global("test123"))) coordinator.navigate(to: destination) - #expect(coordinator.pendingNavigation == destination) + #expect(coordinator.router.stack(for: .home) == [destination]) } - @Test("Multiple navigations update pending navigation") + @Test("Multiple navigations stack in order") func multipleNavigations() { let coordinator = NavigationCoordinator() @@ -626,78 +625,125 @@ struct NavigationCoordinatorTests { coordinator.navigate(to: .video(.id(.global("2")))) coordinator.navigate(to: .video(.id(.global("3")))) - // Only the last navigation is pending - #expect(coordinator.pendingNavigation == .video(.id(.global("3")))) + #expect(coordinator.router.stack(for: .home) == [ + .video(.id(.global("1"))), + .video(.id(.global("2"))), + .video(.id(.global("3"))), + ]) } - @Test("Pop to root clears path") + @Test("Each section keeps its own stack") + func perSectionStacks() { + let coordinator = NavigationCoordinator() + + coordinator.navigate(to: .video(.id(.global("1")))) + coordinator.router.select(.search) + coordinator.navigate(to: .video(.id(.global("2")))) + + #expect(coordinator.router.stack(for: .home) == [.video(.id(.global("1")))]) + #expect(coordinator.router.stack(for: .search) == [.video(.id(.global("2")))]) + } + + @Test("Pop to root clears the selected stack only") func popToRoot() { let coordinator = NavigationCoordinator() + coordinator.navigate(to: .video(.id(.global("1")))) + coordinator.router.select(.search) + coordinator.navigate(to: .video(.id(.global("2")))) - // Manually add to path to test popToRoot - coordinator.path.append(NavigationDestination.video(.id(.global("1")))) - coordinator.path.append(NavigationDestination.video(.id(.global("2")))) - coordinator.path.append(NavigationDestination.video(.id(.global("3")))) + coordinator.router.popToRoot() - #expect(coordinator.path.count == 3) - - coordinator.popToRoot() - - #expect(coordinator.path.isEmpty) + #expect(coordinator.router.stack(for: .search).isEmpty) + #expect(coordinator.router.stack(for: .home).count == 1) } - @Test("Pop removes one level") - func pop() { + @Test("Navigate to subscriptions selects the feed") + func navigateToSubscriptions() { let coordinator = NavigationCoordinator() - // Manually add to path to test pop - coordinator.path.append(NavigationDestination.video(.id(.global("1")))) - coordinator.path.append(NavigationDestination.video(.id(.global("2")))) + coordinator.navigateToSubscriptions() - #expect(coordinator.path.count == 2) - - coordinator.pop() - - #expect(coordinator.path.count == 1) + #expect(coordinator.router.selection == .subscriptionsFeed) } - @Test("Pop on empty path is safe") - func popOnEmptyPath() { - let coordinator = NavigationCoordinator() + @Test("Startup tab applies only once") + func startupTabOnce() { + let router = NavigationRouter() - // Should not crash - coordinator.pop() + router.applyStartupTabIfNeeded(.search) + router.select(.bookmarks) + router.applyStartupTabIfNeeded(.search) - #expect(coordinator.path.isEmpty) + #expect(router.selection == .bookmarks) } - @Test("Switch tab") - func switchTab() { + @Test("A destination pushed before startup keeps its section") + func deepLinkBeforeStartup() { let coordinator = NavigationCoordinator() - coordinator.selectedTab = .search + coordinator.navigate(to: .video(.id(.global("deep")))) + coordinator.router.applyStartupTabIfNeeded(.search) - #expect(coordinator.selectedTab == .search) + #expect(coordinator.router.selection == .home) + #expect(coordinator.router.stack(for: .home) == [.video(.id(.global("deep")))]) + } +} + +// MARK: - NavigationRouter Layout Mapping Tests + +@Suite("NavigationRouter Layout Mapping Tests") +@MainActor +struct NavigationRouterLayoutMappingTests { + + @Test("Available selection is left alone") + func availableSelectionUnchanged() { + let router = NavigationRouter() + router.select(.bookmarks) + router.push(.video(.id(.global("1")))) + + router.ensureSelectionIsAvailable(isAvailable: { _ in true }) + + #expect(router.selection == .bookmarks) + #expect(router.stack(for: .bookmarks).count == 1) } - @Test("Handle URL sets pending navigation") - func handleURL() { - let coordinator = NavigationCoordinator() - let url = URL(string: "https://youtube.com/watch?v=test123")! + @Test("Sidebar channel maps to the subscriptions tab with the channel pushed first") + func channelToSubscriptions() { + let router = NavigationRouter() + let source = ContentSource.global(provider: ContentSource.youtubeProvider) + let channel = SidebarItem.channel(channelID: "UC1", name: "Channel", source: source) + router.select(channel) + router.push(.video(.id(.global("v1")))) - coordinator.handle(url: url) + router.ensureSelectionIsAvailable(isAvailable: { [.home, .search, .subscriptionsFeed].contains($0) }) - #expect(coordinator.pendingNavigation != nil) + #expect(router.selection == .subscriptionsFeed) + #expect(router.stack(for: .subscriptionsFeed) == [.channel("UC1", source), .video(.id(.global("v1")))]) + #expect(router.stack(for: channel).isEmpty) } - @Test("Handle unknown URL does nothing") - func handleUnknownURL() { - let coordinator = NavigationCoordinator() - let url = URL(string: "https://example.com/unknown")! + @Test("Hidden tab falls back to home with its screen pushed") + func hiddenTabToHome() { + let router = NavigationRouter() + router.select(.bookmarks) + router.push(.video(.id(.global("v1")))) - coordinator.handle(url: url) + router.ensureSelectionIsAvailable(isAvailable: { [.home, .search].contains($0) }) - #expect(coordinator.path.isEmpty) + #expect(router.selection == .home) + #expect(router.stack(for: .home) == [.bookmarks, .video(.id(.global("v1")))]) + } + + @Test("Sidebar playlist maps to the playlists tab") + func playlistToPlaylists() { + let router = NavigationRouter() + let id = UUID() + router.select(.playlist(id: id, title: "Mix")) + + router.ensureSelectionIsAvailable(isAvailable: { [.home, .search, .playlistsList].contains($0) }) + + #expect(router.selection == .playlistsList) + #expect(router.stack(for: .playlistsList) == [.playlist(.local(id, title: "Mix"))]) } }