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.
This commit is contained in:
Arkadiusz Fal
2026-10-03 23:24:50 +02:00
parent 79202667c0
commit b26b7d342f
9 changed files with 423 additions and 910 deletions

View File

@@ -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

View File

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

View File

@@ -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
}
}
}

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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<NavigationPath> {
Binding(
get: { channelPaths[item.id] ?? NavigationPath() },
set: { channelPaths[item.id] = $0 }
)
}
func playlistPathBinding(for id: UUID) -> Binding<NavigationPath> {
Binding(
get: { playlistPaths[id] ?? NavigationPath() },
set: { playlistPaths[id] = $0 }
)
}
func mediaSourcePathBinding(for id: UUID) -> Binding<NavigationPath> {
Binding(
get: { mediaSourcePaths[id] ?? NavigationPath() },
set: { mediaSourcePaths[id] = $0 }
)
}
func instancePathBinding(for id: UUID) -> Binding<NavigationPath> {
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<NavigationPath> {
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()
}

View File

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