Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,339 @@
//
// CompactTabView.swift
// Yattee
//
// Custom tab bar for compact size class (iPhone, iPad Stage Manager small window).
// Uses settings-based tab customization since Apple's TabViewCustomization only works in sidebar mode.
//
import SwiftUI
#if os(iOS)
struct CompactTabView: View {
@Environment(\.appEnvironment) private var appEnvironment
// Navigation paths for fixed tabs
@State private var homePath = NavigationPath()
@State private var searchPath = NavigationPath()
// Navigation paths for dynamic tabs
@State private var subscriptionsPath = NavigationPath()
@State private var channelsPath = NavigationPath()
@State private var bookmarksPath = NavigationPath()
@State private var playlistsPath = NavigationPath()
@State private var historyPath = NavigationPath()
@State private var downloadsPath = NavigationPath()
@State private var sourcesPath = NavigationPath()
@State private var settingsPath = NavigationPath()
// Tab selection - using String to support both fixed and dynamic tabs
// Initial value is a placeholder; actual startup tab is applied in onAppear
@State private var selectedTab: String = "home"
@State private var hasAppliedStartupTab = false
// Search text state (iOS 26+ TabView .searchable integration)
@State private var searchText = ""
// Zoom transition namespace (local to this tab view)
@Namespace private var zoomTransition
private var settingsManager: SettingsManager? {
appEnvironment?.settingsManager
}
private var navigationCoordinator: NavigationCoordinator? {
appEnvironment?.navigationCoordinator
}
/// Returns the visible custom tabs from settings
private var visibleTabItems: [TabBarItem] {
settingsManager?.visibleTabBarItems() ?? []
}
/// Whether to show the mini player accessory (iOS 26.1+)
private var shouldShowAccessory: Bool {
guard let state = appEnvironment?.playerService.state else { return false }
return state.currentVideo != nil && !state.isClosingVideo
}
private var zoomTransitionsEnabled: Bool {
appEnvironment?.settingsManager.zoomTransitionsEnabled ?? true
}
var body: some View {
TabView(selection: $selectedTab) {
// Fixed: Home (first)
Tab(value: "home") {
NavigationStack(path: $homePath) {
HomeView()
.withNavigationDestinations()
}
} label: {
Label(String(localized: "tabs.home"), systemImage: "house.fill")
}
.accessibilityIdentifier("tab.home")
// Dynamic tabs from settings (in the middle, SwiftUI auto-collapses overflow into More)
ForEach(visibleTabItems) { item in
Tab(value: item.rawValue) {
tabContent(for: item)
} label: {
Label(item.localizedTitle, systemImage: item.icon)
}
}
// Fixed: Search (last) - with role: .search
Tab(value: "search", role: .search) {
NavigationStack(path: $searchPath) {
SearchView(searchText: $searchText)
.withNavigationDestinations()
}
} label: {
Label(String(localized: "tabs.search"), systemImage: "magnifyingglass")
}
.accessibilityIdentifier("tab.search")
}
.zoomTransitionNamespace(zoomTransition)
.zoomTransitionsEnabled(zoomTransitionsEnabled)
.iOS26TabFeatures(shouldShowAccessory: shouldShowAccessory, settingsManager: settingsManager)
.onChange(of: navigationCoordinator?.pendingNavigation) { _, newValue in
handlePendingNavigation(newValue)
}
.onChange(of: selectedTab) { _, newTab in
updateHandoffForTab(newTab)
syncTabToCoordinator(newTab)
}
.onChange(of: navigationCoordinator?.selectedTab) { _, newTab in
syncTabFromCoordinator(newTab)
}
.onAppear {
applyStartupTabIfNeeded()
}
}
// MARK: - Startup Tab
/// Applies the configured startup tab on first appearance.
private func applyStartupTabIfNeeded() {
guard !hasAppliedStartupTab else { return }
hasAppliedStartupTab = true
let startupTab = settingsManager?.effectiveStartupTabForTabBar() ?? .home
selectedTab = startupTab.compactTabValue
}
// MARK: - Handoff
/// Updates Handoff activity based on the selected tab.
private func updateHandoffForTab(_ tab: String) {
let destination: NavigationDestination?
switch tab {
case "home":
// Home tab - use playlists as default (matches HomeView's primary content)
destination = .playlists
case "search":
// Search updates handoff when a search is performed
destination = nil
case TabBarItem.subscriptions.rawValue:
destination = .subscriptionsFeed
case TabBarItem.channels.rawValue:
destination = .manageChannels
case TabBarItem.bookmarks.rawValue:
destination = .bookmarks
case TabBarItem.playlists.rawValue:
destination = .playlists
case TabBarItem.history.rawValue:
destination = .history
case TabBarItem.downloads.rawValue:
destination = .downloads
case TabBarItem.sources.rawValue:
destination = nil // No handoff for sources
case TabBarItem.settings.rawValue:
destination = nil // No handoff for settings
default:
destination = nil
}
if let destination {
appEnvironment?.handoffManager.updateActivity(for: destination)
}
}
// MARK: - Tab Sync with NavigationCoordinator
/// Syncs NavigationCoordinator's selectedTab to local state (coordinator local).
/// Called when NavigationCoordinator.selectedTab changes (e.g., from notification tap).
private func syncTabFromCoordinator(_ appTab: AppTab?) {
guard let appTab else { return }
switch appTab {
case .home:
if selectedTab != "home" {
selectedTab = "home"
}
case .subscriptions:
if visibleTabItems.contains(.subscriptions) {
// Subscriptions tab is visible - switch to it
let tabValue = TabBarItem.subscriptions.rawValue
if selectedTab != tabValue {
selectedTab = tabValue
}
} else {
// Subscriptions tab not visible - push subscriptions view onto current stack
pushSubscriptionsOnCurrentStack()
}
case .search:
if selectedTab != "search" {
selectedTab = "search"
}
#if os(tvOS)
case .settings:
break
#endif
}
}
/// Syncs local selectedTab to NavigationCoordinator (local coordinator).
/// Called when user manually switches tabs.
private func syncTabToCoordinator(_ tab: String) {
guard let coordinator = navigationCoordinator else { return }
let appTab: AppTab
switch tab {
case "home":
appTab = .home
case "search":
appTab = .search
case TabBarItem.subscriptions.rawValue:
appTab = .subscriptions
default:
// Other tabs (channels, bookmarks, downloads, etc.) don't have AppTab equivalents
// Don't update coordinator - just return to avoid feedback loop
return
}
if coordinator.selectedTab != appTab {
coordinator.selectedTab = appTab
}
}
/// Pushes the subscriptions feed onto the current tab's navigation stack.
/// Used when subscriptions tab is not visible but we need to navigate to subscriptions.
private func pushSubscriptionsOnCurrentStack() {
let destination = NavigationDestination.subscriptionsFeed
switch selectedTab {
case "home":
homePath.append(destination)
case "search":
searchPath.append(destination)
case TabBarItem.channels.rawValue:
channelsPath.append(destination)
case TabBarItem.bookmarks.rawValue:
bookmarksPath.append(destination)
case TabBarItem.playlists.rawValue:
playlistsPath.append(destination)
case TabBarItem.history.rawValue:
historyPath.append(destination)
case TabBarItem.downloads.rawValue:
downloadsPath.append(destination)
case TabBarItem.sources.rawValue:
sourcesPath.append(destination)
case TabBarItem.settings.rawValue:
settingsPath.append(destination)
default:
homePath.append(destination)
}
}
// MARK: - Tab Content
@ViewBuilder
private func tabContent(for item: TabBarItem) -> some View {
switch item {
case .subscriptions:
NavigationStack(path: $subscriptionsPath) {
SubscriptionsView()
.withNavigationDestinations()
}
case .channels:
NavigationStack(path: $channelsPath) {
ManageChannelsView()
.withNavigationDestinations()
}
case .bookmarks:
NavigationStack(path: $bookmarksPath) {
BookmarksListView()
.withNavigationDestinations()
}
case .playlists:
NavigationStack(path: $playlistsPath) {
PlaylistsListView()
.withNavigationDestinations()
}
case .history:
NavigationStack(path: $historyPath) {
HistoryListView()
.withNavigationDestinations()
}
case .downloads:
NavigationStack(path: $downloadsPath) {
DownloadsView()
.withNavigationDestinations()
}
case .sources:
NavigationStack(path: $sourcesPath) {
MediaSourcesView()
.withNavigationDestinations()
}
case .settings:
NavigationStack(path: $settingsPath) {
SettingsView(showCloseButton: false)
.withNavigationDestinations()
}
}
}
// MARK: - Navigation Handling
private func handlePendingNavigation(_ destination: NavigationDestination?) {
guard let destination else { return }
// Append to the current tab's path
switch selectedTab {
case "home":
homePath.append(destination)
case "search":
searchPath.append(destination)
case TabBarItem.subscriptions.rawValue:
subscriptionsPath.append(destination)
case TabBarItem.channels.rawValue:
channelsPath.append(destination)
case TabBarItem.bookmarks.rawValue:
bookmarksPath.append(destination)
case TabBarItem.playlists.rawValue:
playlistsPath.append(destination)
case TabBarItem.history.rawValue:
historyPath.append(destination)
case TabBarItem.downloads.rawValue:
downloadsPath.append(destination)
case TabBarItem.sources.rawValue:
sourcesPath.append(destination)
case TabBarItem.settings.rawValue:
settingsPath.append(destination)
default:
// Fallback to home
homePath.append(destination)
}
navigationCoordinator?.clearPendingNavigation()
}
}
// MARK: - Preview
#Preview {
CompactTabView()
.appEnvironment(.preview)
}
#endif

View File

@@ -0,0 +1,25 @@
//
// SidebarAdaptableModifier.swift
// Yattee
//
// Navigation-related view modifiers.
//
import SwiftUI
// MARK: - Sidebar Adaptable Modifier
#if os(iOS)
struct SidebarAdaptableModifier: ViewModifier {
func body(content: Content) -> some View {
content.tabViewStyle(.sidebarAdaptable)
}
}
extension View {
/// Applies sidebar adaptable style.
func sidebarAdaptable() -> some View {
modifier(SidebarAdaptableModifier())
}
}
#endif

View File

@@ -0,0 +1,153 @@
//
// SidebarChannelIcon.swift
// Yattee
//
// Pre-scaled channel icon for TabSection labels.
// TabSection labels don't support frame/resizable modifiers,
// so we pre-scale the image at the platform image level.
//
import SwiftUI
import Nuke
#if os(macOS)
import AppKit
#else
import UIKit
#endif
/// A channel icon that pre-scales the image for use in TabSection labels.
/// Standard SwiftUI frame/resizable modifiers don't work in Tab labels.
struct SidebarChannelIcon: View {
let url: URL?
let name: String
var authHeader: String?
private let size: CGFloat = 22
@State private var platformImage: PlatformImage?
@State private var isLoading = false
var body: some View {
Group {
if let platformImage, let scaledImage = scaledImage(from: platformImage) {
scaledImage
} else {
// Placeholder - use SF Symbol which scales correctly
Image(systemName: "person.circle.fill")
.symbolRenderingMode(.hierarchical)
}
}
.onAppear {
loadImage()
}
.onChange(of: url) { _, _ in
platformImage = nil
loadImage()
}
}
@ViewBuilder
private func scaledImage(from image: PlatformImage) -> Image? {
#if os(macOS)
if let scaled = image.scaledCircular(to: NSSize(width: size, height: size)) {
Image(nsImage: scaled)
}
#else
if let scaled = image.scaledCircular(to: CGSize(width: size, height: size)) {
Image(uiImage: scaled)
}
#endif
}
private func loadImage() {
guard let request = AvatarURLBuilder.imageRequest(url: url, authHeader: authHeader), !isLoading else { return }
// Check memory cache first (synchronous)
if let cached = ImagePipeline.shared.cache.cachedImage(for: request)?.image {
platformImage = cached
return
}
isLoading = true
Task {
do {
let image = try await ImagePipeline.shared.image(for: request)
await MainActor.run {
platformImage = image
isLoading = false
}
} catch {
await MainActor.run {
isLoading = false
}
}
}
}
}
// MARK: - Platform Image Scaling
#if os(macOS)
private extension NSImage {
func scaledCircular(to targetSize: NSSize) -> NSImage? {
let newImage = NSImage(size: targetSize)
newImage.lockFocus()
// Create circular clipping path
let path = NSBezierPath(ovalIn: NSRect(origin: .zero, size: targetSize))
path.addClip()
NSGraphicsContext.current?.imageInterpolation = .high
draw(
in: NSRect(origin: .zero, size: targetSize),
from: NSRect(origin: .zero, size: size),
operation: .copy,
fraction: 1.0
)
newImage.unlockFocus()
return newImage
}
}
#else
private extension UIImage {
func scaledCircular(to targetSize: CGSize) -> UIImage? {
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { context in
// Create circular clipping path
let rect = CGRect(origin: .zero, size: targetSize)
context.cgContext.addEllipse(in: rect)
context.cgContext.clip()
draw(in: rect)
}
}
}
#endif
// MARK: - Preview
#if !os(tvOS)
#Preview {
List {
Label {
Text("Apple")
} icon: {
SidebarChannelIcon(url: nil, name: "Apple")
}
Label {
Text("Test Channel")
} icon: {
SidebarChannelIcon(
url: URL(string: "https://example.com/avatar.jpg"),
name: "Test"
)
}
}
.listStyle(.sidebar)
}
#endif

View File

@@ -0,0 +1,69 @@
//
// SidebarChannelRow.swift
// Yattee
//
// Compact channel row for sidebar display.
//
import SwiftUI
import NukeUI
struct SidebarChannelRow: View {
let name: String
let avatarURL: URL?
var authHeader: String?
var body: some View {
HStack(spacing: 8) {
// Avatar
LazyImage(request: AvatarURLBuilder.imageRequest(url: avatarURL, authHeader: authHeader)) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
avatarPlaceholder
}
}
.frame(width: 24, height: 24)
.clipShape(Circle())
// Name
Text(name)
.lineLimit(1)
.truncationMode(.tail)
}
}
private var avatarPlaceholder: some View {
Circle()
.fill(.quaternary)
.overlay {
Text(String(name.prefix(1)).uppercased())
.font(.caption2.bold())
.foregroundStyle(.secondary)
}
}
}
// MARK: - Preview
#if !os(tvOS)
#Preview {
List {
SidebarChannelRow(
name: "Technology Reviews",
avatarURL: nil
)
SidebarChannelRow(
name: "Music & Sound Design",
avatarURL: nil
)
SidebarChannelRow(
name: "A Very Long Channel Name That Should Be Truncated",
avatarURL: nil
)
}
.listStyle(.sidebar)
}
#endif

View File

@@ -0,0 +1,152 @@
//
// SidebarPlaylistIcon.swift
// Yattee
//
// Pre-scaled playlist thumbnail for TabSection labels.
// TabSection labels don't support frame/resizable modifiers,
// so we pre-scale the image at the platform image level.
//
import SwiftUI
import Nuke
#if os(macOS)
import AppKit
#else
import UIKit
#endif
/// A playlist thumbnail that pre-scales the image for use in TabSection labels.
/// Standard SwiftUI frame/resizable modifiers don't work in Tab labels.
struct SidebarPlaylistIcon: View {
let url: URL?
// Target size: ~26x15 for 16:9 aspect ratio that fits sidebar row height
private let targetWidth: CGFloat = 26
private let targetHeight: CGFloat = 15
private let cornerRadius: CGFloat = 3
@State private var platformImage: PlatformImage?
@State private var isLoading = false
var body: some View {
Group {
if let platformImage, let scaledImage = scaledImage(from: platformImage) {
scaledImage
} else {
// Fallback - use SF Symbol which scales correctly
Image(systemName: "list.bullet.rectangle")
}
}
.onAppear {
loadImage()
}
.onChange(of: url) { _, _ in
platformImage = nil
loadImage()
}
}
@ViewBuilder
private func scaledImage(from image: PlatformImage) -> Image? {
#if os(macOS)
if let scaled = image.scaledRounded(to: NSSize(width: targetWidth, height: targetHeight), cornerRadius: cornerRadius) {
Image(nsImage: scaled)
}
#else
if let scaled = image.scaledRounded(to: CGSize(width: targetWidth, height: targetHeight), cornerRadius: cornerRadius) {
Image(uiImage: scaled)
}
#endif
}
private func loadImage() {
guard let url, !isLoading else { return }
// Check memory cache first (synchronous)
if let cached = ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: url))?.image {
platformImage = cached
return
}
isLoading = true
Task {
do {
let image = try await ImagePipeline.shared.image(for: url)
await MainActor.run {
platformImage = image
isLoading = false
}
} catch {
await MainActor.run {
isLoading = false
}
}
}
}
}
// MARK: - Platform Image Scaling
#if os(macOS)
private extension NSImage {
func scaledRounded(to targetSize: NSSize, cornerRadius: CGFloat) -> NSImage? {
let newImage = NSImage(size: targetSize)
newImage.lockFocus()
// Create rounded rect clipping path
let path = NSBezierPath(roundedRect: NSRect(origin: .zero, size: targetSize), xRadius: cornerRadius, yRadius: cornerRadius)
path.addClip()
NSGraphicsContext.current?.imageInterpolation = .high
draw(
in: NSRect(origin: .zero, size: targetSize),
from: NSRect(origin: .zero, size: size),
operation: .copy,
fraction: 1.0
)
newImage.unlockFocus()
return newImage
}
}
#else
private extension UIImage {
func scaledRounded(to targetSize: CGSize, cornerRadius: CGFloat) -> UIImage? {
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { context in
// Create rounded rect clipping path
let rect = CGRect(origin: .zero, size: targetSize)
let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
path.addClip()
draw(in: rect)
}
}
}
#endif
// MARK: - Preview
#if !os(tvOS)
#Preview {
List {
Label {
Text("My Playlist")
} icon: {
SidebarPlaylistIcon(url: nil)
}
Label {
Text("With Thumbnail")
} icon: {
SidebarPlaylistIcon(
url: URL(string: "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg")
)
}
}
.listStyle(.sidebar)
}
#endif

View File

@@ -0,0 +1,63 @@
//
// SidebarPlaylistRow.swift
// Yattee
//
// Compact playlist row for sidebar display.
//
import SwiftUI
struct SidebarPlaylistRow: View {
let title: String
let videoCount: Int
var body: some View {
HStack(spacing: 8) {
// Icon
Image(systemName: "list.bullet.rectangle")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.frame(width: 24, height: 24)
// Title and count
Text(title)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
// Video count badge
if videoCount > 0 {
Text("\(videoCount)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
}
// MARK: - Preview
#if !os(tvOS)
#Preview {
List {
SidebarPlaylistRow(
title: "Watch Later",
videoCount: 12
)
SidebarPlaylistRow(
title: "Favorites",
videoCount: 45
)
SidebarPlaylistRow(
title: "A Very Long Playlist Name That Should Be Truncated",
videoCount: 100
)
SidebarPlaylistRow(
title: "Empty Playlist",
videoCount: 0
)
}
.listStyle(.sidebar)
}
#endif

View File

@@ -0,0 +1,946 @@
//
// 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 {
@Binding var selectedTab: AppTab
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
// 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 sourcesPath = NavigationPath()
@State private var settingsPath = 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?.yatteeServerCredentialsManager.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 .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)
}
}
}
@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 {
@Binding var selectedTab: AppTab
@Environment(\.appEnvironment) private var appEnvironment
// 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 sourcesPath = NavigationPath()
@State private var settingsPath = 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?.yatteeServerCredentialsManager.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: 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 .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)
}
}
}
@TabContentBuilder<SidebarItem>
private var sidebarSections: some TabContent<SidebarItem> {
// Unified Sources Section (instances + media sources)
if !sidebarManager.hasNoSources && (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)
}
}
}
}
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)
}
}
}
}
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 - macOS") {
UnifiedTabView(selectedTab: .constant(.home))
.appEnvironment(.preview)
}
#endif
// MARK: - tvOS Implementation
#if os(tvOS)
struct UnifiedTabView: View {
@Binding var selectedTab: AppTab
@Environment(\.appEnvironment) private var appEnvironment
// 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 bookmarksPath = NavigationPath()
@State private var historyPath = NavigationPath()
@State private var subscriptionsFeedPath = NavigationPath()
@State private var manageChannelsPath = NavigationPath()
@State private var sourcesPath = NavigationPath()
@State private var settingsPath = 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?.yatteeServerCredentialsManager.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)
}
}
/// 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 {
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(SidebarItem.nowPlaying.title, 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 .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)
}
}
}
@TabContentBuilder<SidebarItem>
private var sidebarSections: some TabContent<SidebarItem> {
// Sources Section (shows configured instances)
// Note: Media sources are only shown on iOS/macOS
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, _, _):
#if os(iOS) || os(macOS)
mediaSourcePaths[id, default: NavigationPath()].append(destination)
#endif
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 .sources:
sourcesPath.append(destination)
case .settings:
settingsPath.append(destination)
case .nowPlaying:
break // Now Playing is a root tab, not a push 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 }
)
}
#if os(iOS) || os(macOS)
func mediaSourcePathBinding(for id: UUID) -> Binding<NavigationPath> {
Binding(
get: { mediaSourcePaths[id] ?? NavigationPath() },
set: { mediaSourcePaths[id] = $0 }
)
}
#endif
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
#if os(iOS) || os(macOS)
@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()
}
}
}
#endif
// 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:
#if os(iOS) || os(macOS)
mediaSourceContent(for: item)
#else
EmptyView()
#endif
default:
EmptyView()
}
}
}