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"))]) } }