mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
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.
1041 lines
37 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|