Files
yattee/Yattee/Views/Navigation/UnifiedTabView.swift
Arkadiusz Fal cda983651e Restore Settings as sidebar item on macOS
Remove the gear toolbar button that opened Settings as a sheet in the
NavigationSplitView sidebar column, and drop the macOS guard hiding
.settings from SidebarMainItem so it can be added to the sidebar and
rendered in the detail column like other items. The dedicated Settings
window (Cmd+,) is unchanged.
2026-04-23 18:00:42 +02:00

1041 lines
37 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 {
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
}
}
/// 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(let channelID, _, _):
channelPaths[channelID, 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 channelID: String) -> Binding<NavigationPath> {
Binding(
get: { channelPaths[channelID] ?? NavigationPath() },
set: { channelPaths[channelID] = $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: channelID)) {
ChannelView(channelID: channelID, source: source)
.withNavigationDestinations()
}
}
}
@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()
}
}
}
@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()
}
}
}
// 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()
}
}
}
// 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()
}
}
}