// // ContentView.swift // Yattee // // Root content view with tab-based navigation. // import SwiftUI struct ContentView: View { @Environment(\.appEnvironment) private var appEnvironment var body: some View { Group { if let appEnvironment { #if os(iOS) if #available(iOS 26.1, *) { iOS26AdaptiveTabView(appEnvironment: appEnvironment) } else { iOS18AdaptiveTabView(appEnvironment: appEnvironment) } #elseif os(macOS) unifiedContentView(appEnvironment: appEnvironment) #elseif os(tvOS) unifiedContentView(appEnvironment: appEnvironment) #endif } else { ProgressView(String(localized: "common.loading")) } } .toastOverlay() } // MARK: - Unified Content View (macOS 15+, tvOS 18+) @ViewBuilder private func unifiedContentView(appEnvironment: AppEnvironment) -> some View { ZStack(alignment: .bottom) { #if os(macOS) UnifiedTabView( selectedTab: Binding( get: { appEnvironment.navigationCoordinator.selectedTab }, set: { appEnvironment.navigationCoordinator.selectedTab = $0 } ) ) .environment(appEnvironment.settingsManager) #elseif os(tvOS) UnifiedTabView( selectedTab: Binding( get: { appEnvironment.navigationCoordinator.selectedTab }, set: { appEnvironment.navigationCoordinator.selectedTab = $0 } ) ) .environment(appEnvironment.settingsManager) #endif // Mini player overlay (macOS only) #if os(macOS) miniPlayerOverlay(appEnvironment: appEnvironment) // Inline expanded player: presented as a full-window overlay // (no native sheet — a window with an attached sheet can't go // fullscreen, so fullscreen is just the window's native toggle). if isInlinePlayerOverlayActive(appEnvironment: appEnvironment) { ZStack { Color.black ExpandedPlayerSheet() } .ignoresSafeArea() .transition(.opacity) } #endif } #if os(macOS) // Registers the hosting window so the inline overlay targets the real // main window even when another window (e.g. Settings) has focus. .background(MainContentWindowReader()) .animation(.easeInOut(duration: 0.2), value: isInlinePlayerOverlayActive(appEnvironment: appEnvironment)) .onChange(of: isInlinePlayerOverlayActive(appEnvironment: appEnvironment)) { _, active in if active { ExpandedPlayerWindowManager.shared.beginInlineOverlay() } else { ExpandedPlayerWindowManager.shared.endInlineOverlay() } } .onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in if appEnvironment.settingsManager.macPlayerSeparateWindow { presentExpandedPlayerWindow(appEnvironment: appEnvironment) } } .onChange(of: appEnvironment.navigationCoordinator.isPlayerExpanded) { _, isExpanded in if !isExpanded { ExpandedPlayerWindowManager.shared.hide() } } .onChange(of: appEnvironment.settingsManager.macPlayerSeparateWindow) { _, separateWindow in guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return } if separateWindow { // Inline overlay → separate window: the overlay unmounts via the // presentation condition; present the window directly. presentExpandedPlayerWindow(appEnvironment: appEnvironment) } else { // Separate window → inline overlay: hide the window so the overlay // (isPlayerExpanded && !separateWindow) takes over. ExpandedPlayerWindowManager.shared.hide(animated: false) } } .onChange(of: appEnvironment.settingsManager.macPlayerFloating) { _, floating in guard appEnvironment.navigationCoordinator.isPlayerExpanded, appEnvironment.settingsManager.macPlayerSeparateWindow else { return } ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: floating) } .sheet(isPresented: Binding( get: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented }, set: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = $0 } )) { QueueManagementSheet() } .sheet(isPresented: Binding( get: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented }, set: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented = $0 } )) { if let video = appEnvironment.playerService.state.currentVideo { PlaylistSelectorSheet(video: video) } } #elseif os(tvOS) .fullScreenCover(isPresented: Binding( get: { appEnvironment.navigationCoordinator.isPlayerExpanded }, set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 } )) { TVPlayerView() } #endif } #if os(macOS) private func presentExpandedPlayerWindow(appEnvironment: AppEnvironment) { ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: true) } /// Whether the inline (non-separate-window) expanded player overlay should /// be presented. Shared by the overlay mount and the begin/end onChange so /// the two can't drift apart. private func isInlinePlayerOverlayActive(appEnvironment: AppEnvironment) -> Bool { appEnvironment.navigationCoordinator.isPlayerExpanded && !appEnvironment.settingsManager.macPlayerSeparateWindow } #endif #if os(macOS) @ViewBuilder private func miniPlayerOverlay(appEnvironment: AppEnvironment) -> some View { let playerState = appEnvironment.playerService.state let hasActiveVideo = playerState.currentVideo != nil let isExpanded = appEnvironment.navigationCoordinator.isPlayerExpanded // The expanded player is a separate window in window mode, so keep the capsule // visible alongside it. The inline overlay covers the window, so hide it there. let usesWindow = appEnvironment.settingsManager.macPlayerSeparateWindow if hasActiveVideo && (!isExpanded || usesWindow) { VStack(spacing: 0) { Spacer() MiniPlayerView() } // Float the capsule above the bottom edge (macOS uses a sidebar, not a tab bar) .padding(.bottom, 16) // Use move-only transition (no opacity) to prevent thumbnail flash during collapse .transition(.move(edge: .bottom)) .animation(.spring(response: 0.3), value: hasActiveVideo) } } #endif } // MARK: - iOS 18+ Adaptive Tab View #if os(iOS) /// Switches between CompactTabView and UnifiedTabView based on horizontal size class. /// Compact width (iPhone, iPad Stage Manager small): CompactTabView with settings-based customization /// Regular width (iPad full, iPad larger windows): UnifiedTabView with sidebar adaptable struct iOS18AdaptiveTabView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass let appEnvironment: AppEnvironment @State private var showingMiniPlayerQueueSheet = false @State private var showingMiniPlayerPlaylistSheet = false /// Whether to show the black overlay that covers scaled content when player is expanded private var shouldShowExpandedOverlay: Bool { let nav = appEnvironment.navigationCoordinator // Show overlay only when: // - Player window is actually visible (not just isPlayerExpanded intent) // - Expand animation has completed (not animating) // - Dismiss gesture is not active (so user can see scaled content during drag) return nav.isPlayerWindowVisible && !nav.isPlayerSheetAnimating && !nav.isPlayerDismissGestureActive } /// Whether to show loading spinner while waiting for player window to appear private var shouldShowExpandPendingSpinner: Bool { let nav = appEnvironment.navigationCoordinator // Show spinner when expand is requested but window isn't visible yet return nav.isPlayerExpanded && !nav.isPlayerWindowVisible && !nav.isPlayerSheetAnimating } var body: some View { ZStack(alignment: .bottom) { if horizontalSizeClass == .compact || UIDevice.current.userInterfaceIdiom == .phone { CompactTabView() .environment(appEnvironment.settingsManager) } else { UnifiedTabView( selectedTab: Binding( get: { appEnvironment.navigationCoordinator.selectedTab }, set: { appEnvironment.navigationCoordinator.selectedTab = $0 } ) ) .environment(appEnvironment.settingsManager) } // Mini player overlay miniPlayerOverlay // Black overlay when player is fully expanded // Hides scaled content behind player sheet, removed during dismiss gesture for parallax effect if shouldShowExpandedOverlay { Color.black .ignoresSafeArea() .transaction { $0.animation = nil } } // Loading spinner when expand is pending but window not yet visible // This handles the case when scene is transitioning (e.g., Control Center) if shouldShowExpandPendingSpinner { ZStack { Color.black.opacity(0.7) ProgressView() .tint(.white) .scaleEffect(1.2) } .ignoresSafeArea() .transition(.opacity.animation(.easeInOut(duration: 0.2))) } } .onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in presentExpandedPlayer() } .onChange(of: appEnvironment.navigationCoordinator.isPlayerExpanded) { _, isExpanded in if !isExpanded { ExpandedPlayerWindowManager.shared.hide() } } .onChange(of: appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented) { _, newValue in showingMiniPlayerQueueSheet = newValue } .onChange(of: appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented) { _, newValue in showingMiniPlayerPlaylistSheet = newValue } .sheet(isPresented: $showingMiniPlayerQueueSheet, onDismiss: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = false }) { QueueManagementSheet() .presentationDetents([.medium, .large]) } .sheet(isPresented: $showingMiniPlayerPlaylistSheet, onDismiss: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented = false }) { if let video = appEnvironment.playerService.state.currentVideo { PlaylistSelectorSheet(video: video) .presentationDetents([.medium, .large]) } } } @ViewBuilder private var miniPlayerOverlay: some View { let playerState = appEnvironment.playerService.state let hasActiveVideo = playerState.currentVideo != nil let isExpanded = appEnvironment.navigationCoordinator.isPlayerExpanded if hasActiveVideo && !isExpanded { VStack(spacing: 0) { Spacer() MiniPlayerView() } .padding(.bottom, 49) // Use move-only transition (no opacity) to prevent thumbnail flash during collapse // The opacity transition caused video to appear faded during player sheet dismiss .transition(.move(edge: .bottom)) .animation(.spring(response: 0.3), value: hasActiveVideo) } } private func presentExpandedPlayer() { let shouldAnimate = !appEnvironment.navigationCoordinator.skipNextPlayerExpandAnimation appEnvironment.navigationCoordinator.skipNextPlayerExpandAnimation = false ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: shouldAnimate) } } // MARK: - iOS 26+ Adaptive Tab View /// iOS 26.1+ version with bottom accessory mini player support. /// Uses UnifiedTabView with sidebarAdaptable for regular width, CompactTabView for compact. @available(iOS 26.1, *) struct iOS26AdaptiveTabView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass let appEnvironment: AppEnvironment @State private var showingMiniPlayerQueueSheet = false @State private var showingMiniPlayerPlaylistSheet = false /// Whether to show the black overlay that covers scaled content when player is expanded private var shouldShowExpandedOverlay: Bool { let nav = appEnvironment.navigationCoordinator // Show overlay only when: // - Player window is actually visible (not just isPlayerExpanded intent) // - Expand animation has completed (not animating) // - Dismiss gesture is not active (so user can see scaled content during drag) return nav.isPlayerWindowVisible && !nav.isPlayerSheetAnimating && !nav.isPlayerDismissGestureActive } /// Whether to show loading spinner while waiting for player window to appear private var shouldShowExpandPendingSpinner: Bool { let nav = appEnvironment.navigationCoordinator // Show spinner when expand is requested but window isn't visible yet return nav.isPlayerExpanded && !nav.isPlayerWindowVisible && !nav.isPlayerSheetAnimating } var body: some View { ZStack { Group { if horizontalSizeClass == .compact || UIDevice.current.userInterfaceIdiom == .phone { // Compact: Use CompactTabView with settings-based customization // No bottom accessory since we're not using sidebarAdaptable style CompactTabView() .environment(appEnvironment.settingsManager) } else { // Regular: Use UnifiedTabView which has bottom accessory support via sidebarAdaptable UnifiedTabView( selectedTab: Binding( get: { appEnvironment.navigationCoordinator.selectedTab }, set: { appEnvironment.navigationCoordinator.selectedTab = $0 } ) ) .environment(appEnvironment.settingsManager) } } // Black overlay when player is fully expanded // Hides scaled content behind player sheet, removed during dismiss gesture for parallax effect if shouldShowExpandedOverlay { Color.black .ignoresSafeArea() .transaction { $0.animation = nil } } // Loading spinner when expand is pending but window not yet visible // This handles the case when scene is transitioning (e.g., Control Center) if shouldShowExpandPendingSpinner { ZStack { Color.black.opacity(0.7) ProgressView() .tint(.white) .scaleEffect(1.2) } .ignoresSafeArea() .transition(.opacity.animation(.easeInOut(duration: 0.2))) } } .onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in presentExpandedPlayer() } .onChange(of: appEnvironment.navigationCoordinator.isPlayerExpanded) { _, isExpanded in if !isExpanded { ExpandedPlayerWindowManager.shared.hide() } } .onChange(of: appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented) { _, newValue in showingMiniPlayerQueueSheet = newValue } .onChange(of: appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented) { _, newValue in showingMiniPlayerPlaylistSheet = newValue } .sheet(isPresented: $showingMiniPlayerQueueSheet, onDismiss: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = false }) { QueueManagementSheet() .presentationDetents([.medium, .large]) } .sheet(isPresented: $showingMiniPlayerPlaylistSheet, onDismiss: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented = false }) { if let video = appEnvironment.playerService.state.currentVideo { PlaylistSelectorSheet(video: video) .presentationDetents([.medium, .large]) } } } private func presentExpandedPlayer() { let shouldAnimate = !appEnvironment.navigationCoordinator.skipNextPlayerExpandAnimation appEnvironment.navigationCoordinator.skipNextPlayerExpandAnimation = false ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: shouldAnimate) } } #endif // MARK: - Preview #Preview { ContentView() .appEnvironment(.preview) }