Files
yattee/Yattee/Views/Navigation/UnifiedTabView.swift
Arkadiusz Fal 175b98097a Put the account switcher where the data is, and retire the old setting
Switching accounts was buried in Subscriptions settings behind a picker
and a confirmation dialog, and it wrote two stores that could disagree.
One shared switcher now sits in the Subscriptions and Playlists
toolbars, the macOS sidebar footer and the Settings root, and every path
goes through AppEnvironment.switchDataContext(to:) so a switch behaves
the same wherever it is made. Switching is one tap: it is reversible, so
there is nothing to confirm.

Rows come from DataContextValidator.availableContexts, name themselves
through displayName(for:), and carry a status suffix when the account is
not signed in — plus the instance host when two accounts share a name.
tvOS gets the same rows as a full-screen picker.

Subscriptions also explains itself when the selected account cannot
answer: a thin banner names the cause and says the data below is local.
This is the first consumer of the validator's unavailability, and
correctIfContextRemoved() now runs on foreground activation, where
SubscriptionAccountValidator used to auto-correct any selection it could
not immediately verify.

With the switcher reading the context directly, the legacy
subscriptionAccount setting has no readers left: the setting, its
validator, the mirror that kept it in step and the feed cache's
instance-keyed fallbacks all go. The cache invalidation the mirror also
carried stays, under its own name. AccountsManager still reads the raw
legacy key for the one-way context migration.
2026-09-03 23:59:32 +02:00

1111 lines
40 KiB
Swift

//
// UnifiedTabView.swift
// Yattee
//
// Unified tab view with sidebar sections.
// Uses TabSection to group navigation items and display user data (channels, playlists).
// iOS 26.1+ gets additional features: bottom accessory mini player and tab bar minimize behavior.
//
import SwiftUI
// MARK: - iOS Implementation
#if os(iOS)
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 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
// Zoom transition namespace (local to this tab view)
@Namespace private var zoomTransition
private var shouldShowAccessory: Bool {
guard let state = appEnvironment?.playerService.state else { return false }
return state.currentVideo != nil
}
private var zoomTransitionsEnabled: Bool {
appEnvironment?.settingsManager.zoomTransitionsEnabled ?? true
}
private var yatteeServerAuthHeader: String? {
guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil }
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
}
var body: some View {
TabView(selection: $selection) {
mainTabs
sidebarSections
}
.tabViewStyle(.sidebarAdaptable)
.iOS26TabFeatures(shouldShowAccessory: shouldShowAccessory, settingsManager: appEnvironment?.settingsManager)
.zoomTransitionNamespace(zoomTransition)
.zoomTransitionsEnabled(zoomTransitionsEnabled)
.onAppear {
configureSidebarManager()
applyStartupTabIfNeeded()
}
.onChange(of: navigationCoordinator?.pendingNavigation) { _, newValue in
handlePendingNavigation(newValue)
}
.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
private var visibleMainItems: [SidebarMainItem] {
settingsManager?.visibleSidebarMainItems() ?? SidebarMainItem.defaultOrder
}
// MARK: - Tab Builders
@TabContentBuilder<SidebarItem>
private var mainTabs: some TabContent<SidebarItem> {
ForEach(visibleMainItems) { item in
mainTab(for: item)
}
}
@TabContentBuilder<SidebarItem>
private func mainTab(for item: SidebarMainItem) -> some TabContent<SidebarItem> {
switch item {
case .search:
Tab(value: SidebarItem.search, role: .search) {
NavigationStack(path: $searchPath) {
SearchView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.search.title, systemImage: SidebarItem.search.systemImage)
}
case .home:
Tab(value: SidebarItem.home) {
NavigationStack(path: $homePath) {
HomeView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.home.title, systemImage: SidebarItem.home.systemImage)
}
case .subscriptions:
Tab(value: SidebarItem.subscriptionsFeed) {
NavigationStack(path: $subscriptionsFeedPath) {
SubscriptionsView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.subscriptionsFeed.title, systemImage: SidebarItem.subscriptionsFeed.systemImage)
}
case .bookmarks:
Tab(value: SidebarItem.bookmarks) {
NavigationStack(path: $bookmarksPath) {
BookmarksListView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.bookmarks.title, systemImage: SidebarItem.bookmarks.systemImage)
}
case .history:
Tab(value: SidebarItem.history) {
NavigationStack(path: $historyPath) {
HistoryListView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.history.title, systemImage: SidebarItem.history.systemImage)
}
case .downloads:
Tab(value: SidebarItem.downloads) {
NavigationStack(path: $downloadsPath) {
DownloadsView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.downloads.title, systemImage: SidebarItem.downloads.systemImage)
}
case .channels:
Tab(value: SidebarItem.manageChannels) {
NavigationStack(path: $manageChannelsPath) {
ManageChannelsView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.manageChannels.title, systemImage: SidebarItem.manageChannels.systemImage)
}
case .playlists:
Tab(value: SidebarItem.playlistsList) {
NavigationStack(path: $playlistsListPath) {
PlaylistsListView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.playlistsList.title, systemImage: SidebarItem.playlistsList.systemImage)
}
case .sources:
Tab(value: SidebarItem.sources) {
NavigationStack(path: $sourcesPath) {
MediaSourcesView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.sources.title, systemImage: SidebarItem.sources.systemImage)
}
case .settings:
Tab(value: SidebarItem.settings) {
NavigationStack(path: $settingsPath) {
SettingsView(showCloseButton: false)
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.settings.title, systemImage: SidebarItem.settings.systemImage)
}
case .openURL:
Tab(value: SidebarItem.openURL) {
NavigationStack(path: $openURLPath) {
OpenLinkView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.openURL.title, systemImage: SidebarItem.openURL.systemImage)
}
case .remoteControl:
Tab(value: SidebarItem.remoteControl) {
NavigationStack(path: $remoteControlPath) {
RemoteControlContentView(navigationStyle: .link)
.navigationTitle(String(localized: "remoteControl.title"))
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.remoteControl.title, systemImage: SidebarItem.remoteControl.systemImage)
}
case .continueWatching:
Tab(value: SidebarItem.continueWatching) {
NavigationStack(path: $continueWatchingPath) {
ContinueWatchingView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.continueWatching.title, systemImage: SidebarItem.continueWatching.systemImage)
}
}
}
@TabContentBuilder<SidebarItem>
private var sidebarSections: some TabContent<SidebarItem> {
// Unified Sources Section (sidebar only - shows instances + media sources)
TabSection(String(localized: "sidebar.section.sources")) {
ForEach(sidebarManager.sortedSourceItems) { item in
Tab(value: item) {
sourceContent(for: item)
} label: {
Label(item.title, systemImage: item.systemImage)
}
}
}
.defaultVisibility(.hidden, for: .tabBar)
.hidden(
horizontalSizeClass == .compact ||
sidebarManager.hasNoSources ||
!(settingsManager?.sidebarSourcesEnabled ?? true)
)
// Channels Section (sidebar only - shows subscribed channels)
TabSection(String(localized: "sidebar.section.channels")) {
ForEach(sidebarManager.channelItems) { item in
Tab(value: item) {
channelContent(for: item)
} label: {
channelLabel(for: item)
}
}
}
.defaultVisibility(.hidden, for: .tabBar)
.hidden(
horizontalSizeClass == .compact ||
sidebarManager.channelItems.isEmpty ||
!(settingsManager?.sidebarChannelsEnabled ?? true)
)
// Playlists Section (sidebar only)
TabSection(String(localized: "sidebar.section.playlists")) {
ForEach(sidebarManager.playlistItems) { item in
Tab(value: item) {
playlistContent(for: item)
} label: {
playlistLabel(for: item)
}
}
}
.defaultVisibility(.hidden, for: .tabBar)
.hidden(
horizontalSizeClass == .compact ||
sidebarManager.playlistItems.isEmpty ||
!(settingsManager?.sidebarPlaylistsEnabled ?? true)
)
}
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
}
// iOS 26.1+ Tab Features
extension View {
@ViewBuilder
func iOS26TabFeatures(shouldShowAccessory: Bool, settingsManager: SettingsManager?) -> some View {
if #available(iOS 26.1, *) {
let behavior = settingsManager?.miniPlayerMinimizeBehavior.tabBarMinimizeBehavior ?? .onScrollDown
self
.tabViewBottomAccessory(isEnabled: shouldShowAccessory) {
MiniPlayerView(isTabAccessory: true)
}
.tabBarMinimizeBehavior(behavior)
} else {
self
}
}
}
@available(iOS 26, *)
extension MiniPlayerMinimizeBehavior {
var tabBarMinimizeBehavior: TabBarMinimizeBehavior {
switch self {
case .onScrollDown:
return .onScrollDown
case .never:
return .never
}
}
}
#Preview("Unified Tab View - iOS") {
UnifiedTabView(selectedTab: .constant(.home))
.appEnvironment(.preview)
}
#endif
// MARK: - macOS Implementation
#if os(macOS)
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 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
// Zoom transition namespace (local to this tab view)
@Namespace private var zoomTransition
private var yatteeServerAuthHeader: String? {
guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil }
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
}
var body: some View {
NavigationSplitView {
sidebar
} detail: {
detailForSelection
}
.zoomTransitionNamespace(zoomTransition)
.onAppear {
configureSidebarManager()
applyStartupTabIfNeeded()
}
.onChange(of: navigationCoordinator?.pendingNavigation) { _, newValue in
handlePendingNavigation(newValue)
}
.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
private var visibleMainItems: [SidebarMainItem] {
settingsManager?.visibleSidebarMainItems() ?? SidebarMainItem.defaultOrder
}
// MARK: - Sidebar
private var sidebar: some View {
#if os(macOS)
VStack(spacing: 0) {
sidebarList
Divider()
// The window has no toolbar of its own to hang this off, and the
// sidebar is the one chrome every tab shares.
DataContextSwitcherSettingsRow()
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
#else
sidebarList
#endif
}
private var sidebarList: some View {
List(selection: $selection) {
ForEach(visibleMainItems) { item in
sidebarRow(for: item)
}
if !sidebarManager.hasNoSources && (settingsManager?.sidebarSourcesEnabled ?? true) {
Section(String(localized: "sidebar.section.sources")) {
ForEach(sidebarManager.sortedSourceItems) { item in
NavigationLink(value: item) {
Label(item.title, systemImage: item.systemImage)
}
}
}
}
if !sidebarManager.channelItems.isEmpty && (settingsManager?.sidebarChannelsEnabled ?? true) {
Section(String(localized: "sidebar.section.channels")) {
ForEach(sidebarManager.channelItems) { item in
NavigationLink(value: item) {
channelLabel(for: item)
}
}
}
}
if !sidebarManager.playlistItems.isEmpty && (settingsManager?.sidebarPlaylistsEnabled ?? true) {
Section(String(localized: "sidebar.section.playlists")) {
ForEach(sidebarManager.playlistItems) { item in
NavigationLink(value: item) {
playlistLabel(for: item)
}
}
}
}
}
}
@ViewBuilder
private func sidebarRow(for item: SidebarMainItem) -> some View {
let sidebarItem = item.sidebarItem
NavigationLink(value: sidebarItem) {
Label(sidebarItem.title, systemImage: sidebarItem.systemImage)
}
}
// MARK: - Detail
@ViewBuilder
private var detailForSelection: some View {
switch selection {
case .home:
NavigationStack(path: $homePath) {
HomeView().withNavigationDestinations()
}
case .search:
NavigationStack(path: $searchPath) {
SearchView().withNavigationDestinations()
}
case .subscriptionsFeed:
NavigationStack(path: $subscriptionsFeedPath) {
SubscriptionsView().withNavigationDestinations()
}
case .bookmarks:
NavigationStack(path: $bookmarksPath) {
BookmarksListView().withNavigationDestinations()
}
case .history:
NavigationStack(path: $historyPath) {
HistoryListView().withNavigationDestinations()
}
case .downloads:
NavigationStack(path: $downloadsPath) {
DownloadsView().withNavigationDestinations()
}
case .manageChannels:
NavigationStack(path: $manageChannelsPath) {
ManageChannelsView().withNavigationDestinations()
}
case .playlistsList:
NavigationStack(path: $playlistsListPath) {
PlaylistsListView().withNavigationDestinations()
}
case .sources:
NavigationStack(path: $sourcesPath) {
MediaSourcesView().withNavigationDestinations()
}
case .settings:
NavigationStack(path: $settingsPath) {
SettingsView().withNavigationDestinations()
}
case .openURL:
NavigationStack(path: $openURLPath) {
OpenLinkView().withNavigationDestinations()
}
case .remoteControl:
NavigationStack(path: $remoteControlPath) {
RemoteControlContentView(navigationStyle: .link)
.navigationTitle(String(localized: "remoteControl.title"))
.withNavigationDestinations()
}
case .continueWatching:
NavigationStack(path: $continueWatchingPath) {
ContinueWatchingView()
.withNavigationDestinations()
}
case .channel:
channelContent(for: selection)
case .playlist:
playlistContent(for: selection)
case .mediaSource, .instance:
sourceContent(for: selection)
case .nowPlaying:
EmptyView()
}
}
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
}
#Preview("Unified Tab View - macOS") {
UnifiedTabView(selectedTab: .constant(.home))
.appEnvironment(.preview)
}
#endif
// MARK: - tvOS Implementation
#if os(tvOS)
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 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
// Zoom transition namespace (local to this tab view)
@Namespace private var zoomTransition
private var shouldShowNowPlaying: Bool {
guard let state = appEnvironment?.playerService.state else { return false }
let isExpanded = appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
return state.currentVideo != nil && !isExpanded
}
private var yatteeServerAuthHeader: String? {
guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil }
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
}
var body: some View {
TabView(selection: $selection) {
mainTabs
sidebarSections
}
.tabViewStyle(.sidebarAdaptable)
.zoomTransitionNamespace(zoomTransition)
.onAppear {
configureSidebarManager()
applyStartupTabIfNeeded()
}
.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)
// 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
// detail visible until the user manually presses Menu.
NotificationCenter.default.post(name: .yatteeTVForcePopDetail, object: nil)
}
}
/// 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 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] {
settingsManager?.visibleSidebarMainItems() ?? SidebarMainItem.defaultOrder
}
// MARK: - Tab Content Builders
@TabContentBuilder<SidebarItem>
private var mainTabs: some TabContent<SidebarItem> {
// Now Playing (only shown when video is playing and player collapsed)
if shouldShowNowPlaying,
let currentVideo = appEnvironment?.playerService.state.currentVideo {
Tab(value: SidebarItem.nowPlaying) {
Color.clear
.onAppear {
appEnvironment?.navigationCoordinator.expandPlayer()
// Reset selection to prevent immediate re-trigger when player is collapsed
selection = .home
}
} label: {
Label("\(currentVideo.title)\n\(currentVideo.author.name)", systemImage: SidebarItem.nowPlaying.systemImage)
}
}
ForEach(visibleMainItems) { item in
mainTab(for: item)
}
}
@TabContentBuilder<SidebarItem>
private func mainTab(for item: SidebarMainItem) -> some TabContent<SidebarItem> {
switch item {
case .search:
Tab(value: SidebarItem.search, role: .search) {
NavigationStack(path: $searchPath) {
SearchView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.search.title, systemImage: SidebarItem.search.systemImage)
}
case .home:
Tab(value: SidebarItem.home) {
NavigationStack(path: $homePath) {
HomeView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.home.title, systemImage: SidebarItem.home.systemImage)
}
case .subscriptions:
Tab(value: SidebarItem.subscriptionsFeed) {
NavigationStack(path: $subscriptionsFeedPath) {
SubscriptionsView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.subscriptionsFeed.title, systemImage: SidebarItem.subscriptionsFeed.systemImage)
}
case .bookmarks:
Tab(value: SidebarItem.bookmarks) {
NavigationStack(path: $bookmarksPath) {
BookmarksListView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.bookmarks.title, systemImage: SidebarItem.bookmarks.systemImage)
}
case .history:
Tab(value: SidebarItem.history) {
NavigationStack(path: $historyPath) {
HistoryListView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.history.title, systemImage: SidebarItem.history.systemImage)
}
case .downloads:
// Downloads not available on tvOS
// This case won't be reached due to isAvailableOnCurrentPlatform filtering
Tab(value: SidebarItem.home) {
EmptyView()
}
case .channels:
Tab(value: SidebarItem.manageChannels) {
NavigationStack(path: $manageChannelsPath) {
ManageChannelsView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.manageChannels.title, systemImage: SidebarItem.manageChannels.systemImage)
}
case .playlists:
Tab(value: SidebarItem.playlistsList) {
NavigationStack(path: $playlistsListPath) {
PlaylistsListView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.playlistsList.title, systemImage: SidebarItem.playlistsList.systemImage)
}
case .sources:
Tab(value: SidebarItem.sources) {
NavigationStack(path: $sourcesPath) {
MediaSourcesView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.sources.title, systemImage: SidebarItem.sources.systemImage)
}
case .settings:
Tab(value: SidebarItem.settings) {
NavigationStack(path: $settingsPath) {
SettingsView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.settings.title, systemImage: SidebarItem.settings.systemImage)
}
case .openURL:
Tab(value: SidebarItem.openURL) {
NavigationStack(path: $openURLPath) {
TVSidebarDetailContainer(
systemImage: SidebarItem.openURL.systemImage,
title: SidebarItem.openURL.title
) {
OpenLinkView()
}
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.openURL.title, systemImage: SidebarItem.openURL.systemImage)
}
case .remoteControl:
Tab(value: SidebarItem.remoteControl) {
NavigationStack(path: $remoteControlPath) {
TVSidebarDetailContainer(
systemImage: SidebarItem.remoteControl.systemImage,
title: SidebarItem.remoteControl.title
) {
RemoteControlContentView(navigationStyle: .link)
}
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.remoteControl.title, systemImage: SidebarItem.remoteControl.systemImage)
}
case .continueWatching:
Tab(value: SidebarItem.continueWatching) {
NavigationStack(path: $continueWatchingPath) {
ContinueWatchingView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.continueWatching.title, systemImage: SidebarItem.continueWatching.systemImage)
}
}
}
@TabContentBuilder<SidebarItem>
private var sidebarSections: some TabContent<SidebarItem> {
// Sources Section (shows configured instances and media sources)
if !sidebarManager.sortedSourceItems.isEmpty && (settingsManager?.sidebarSourcesEnabled ?? true) {
TabSection(String(localized: "sidebar.section.sources")) {
ForEach(sidebarManager.sortedSourceItems) { item in
Tab(value: item) {
sourceContent(for: item)
} label: {
Label(item.title, systemImage: item.systemImage)
}
}
}
}
// Channels Section (shows subscribed channels)
if !sidebarManager.channelItems.isEmpty && (settingsManager?.sidebarChannelsEnabled ?? true) {
TabSection(String(localized: "sidebar.section.channels")) {
ForEach(sidebarManager.channelItems) { item in
Tab(value: item) {
channelContent(for: item)
} label: {
channelLabel(for: item)
}
}
}
}
// Playlists Section
if !sidebarManager.playlistItems.isEmpty && (settingsManager?.sidebarPlaylistsEnabled ?? true) {
TabSection(String(localized: "sidebar.section.playlists")) {
ForEach(sidebarManager.playlistItems) { item in
Tab(value: item) {
playlistContent(for: item)
} label: {
playlistLabel(for: item)
}
}
}
}
}
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
}
#Preview("Unified Tab View - tvOS") {
UnifiedTabView(selectedTab: .constant(.home))
.appEnvironment(.preview)
}
#endif
// MARK: - Shared Helpers
extension UnifiedTabView {
// MARK: - Computed Properties
var navigationCoordinator: NavigationCoordinator? {
appEnvironment?.navigationCoordinator
}
// MARK: - Configuration
func configureSidebarManager() {
guard let appEnvironment else { return }
sidebarManager.configure(
dataManager: appEnvironment.dataManager,
settingsManager: appEnvironment.settingsManager,
mediaSourcesManager: appEnvironment.mediaSourcesManager,
instancesManager: appEnvironment.instancesManager
)
}
// MARK: - Navigation
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 .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 }
)
}
// MARK: - Channel Content & Labels
@ViewBuilder
func channelContent(for item: SidebarItem) -> some View {
if case .channel(let channelID, _, let source) = item {
NavigationStack(path: channelPathBinding(for: item)) {
ChannelView(channelID: channelID, source: source)
.withNavigationDestinations()
}
.id(item.id)
}
}
@ViewBuilder
func channelLabel(for item: SidebarItem) -> some View {
if case .channel(_, let name, _) = item {
Label {
Text(name)
} icon: {
SidebarChannelIcon(
url: sidebarManager.avatarURL(for: item),
name: name,
authHeader: yatteeServerAuthHeader
)
}
}
}
// MARK: - Playlist Content & Labels
@ViewBuilder
func playlistContent(for item: SidebarItem) -> some View {
if case .playlist(let id, _) = item {
NavigationStack(path: playlistPathBinding(for: id)) {
UnifiedPlaylistDetailView(source: .local(id))
.withNavigationDestinations()
}
.id(item.id)
}
}
@ViewBuilder
func playlistLabel(for item: SidebarItem) -> some View {
if case .playlist(_, let title) = item {
Label {
Text(title)
} icon: {
SidebarPlaylistIcon(url: sidebarManager.thumbnailURL(for: item))
}
}
}
// MARK: - Media Source Content
@ViewBuilder
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)) {
MediaBrowserView(source: source, path: "/")
.withNavigationDestinations()
}
.id(item.id)
}
}
// MARK: - Instance Content
@ViewBuilder
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)) {
InstanceBrowseView(instance: instance)
.withNavigationDestinations()
}
.id(item.id)
}
}
// MARK: - Unified Source Content
/// Renders content for any source item (instance or media source).
@ViewBuilder
func sourceContent(for item: SidebarItem) -> some View {
switch item {
case .instance:
instanceContent(for: item)
case .mediaSource:
mediaSourceContent(for: item)
default:
EmptyView()
}
}
}