mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
339
Yattee/Views/Navigation/CompactTabView.swift
Normal file
339
Yattee/Views/Navigation/CompactTabView.swift
Normal 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
|
||||
25
Yattee/Views/Navigation/SidebarAdaptableModifier.swift
Normal file
25
Yattee/Views/Navigation/SidebarAdaptableModifier.swift
Normal 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
|
||||
153
Yattee/Views/Navigation/SidebarChannelIcon.swift
Normal file
153
Yattee/Views/Navigation/SidebarChannelIcon.swift
Normal 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
|
||||
69
Yattee/Views/Navigation/SidebarChannelRow.swift
Normal file
69
Yattee/Views/Navigation/SidebarChannelRow.swift
Normal 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
|
||||
152
Yattee/Views/Navigation/SidebarPlaylistIcon.swift
Normal file
152
Yattee/Views/Navigation/SidebarPlaylistIcon.swift
Normal 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
|
||||
63
Yattee/Views/Navigation/SidebarPlaylistRow.swift
Normal file
63
Yattee/Views/Navigation/SidebarPlaylistRow.swift
Normal 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
|
||||
946
Yattee/Views/Navigation/UnifiedTabView.swift
Normal file
946
Yattee/Views/Navigation/UnifiedTabView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user