mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
SettingsView owns its navigation stack, so pushing it onto a section's stack rendered nothing. Select the Settings section where the layout has one, otherwise present Home's settings sheet; open the Settings window on macOS.
289 lines
12 KiB
Swift
289 lines
12 KiB
Swift
//
|
|
// ContentView.swift
|
|
// Yattee
|
|
//
|
|
// Root content view with tab-based navigation.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
#if os(macOS)
|
|
@Environment(\.openWindow) private var openWindow
|
|
#endif
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let appEnvironment {
|
|
#if os(iOS)
|
|
AdaptiveTabView(appEnvironment: appEnvironment)
|
|
#elseif os(macOS)
|
|
unifiedContentView(appEnvironment: appEnvironment)
|
|
#elseif os(tvOS)
|
|
unifiedContentView(appEnvironment: appEnvironment)
|
|
#endif
|
|
} else {
|
|
ProgressView(String(localized: "common.loading"))
|
|
}
|
|
}
|
|
// Shared sheets for the main window. The expanded player hosts its own,
|
|
// and takes global requests while it is up.
|
|
.appSheetHost(
|
|
isTopLevel: appEnvironment?.navigationCoordinator.isPlayerExpanded != true,
|
|
dismissesOnExternalNavigation: true
|
|
)
|
|
.toastOverlay()
|
|
}
|
|
|
|
// MARK: - Unified Content View (macOS 15+, tvOS 18+)
|
|
|
|
@ViewBuilder
|
|
private func unifiedContentView(appEnvironment: AppEnvironment) -> some View {
|
|
ZStack(alignment: .bottom) {
|
|
#if os(macOS)
|
|
UnifiedTabView(router: appEnvironment.navigationCoordinator.router)
|
|
.environment(appEnvironment.settingsManager)
|
|
#elseif os(tvOS)
|
|
UnifiedTabView(router: appEnvironment.navigationCoordinator.router)
|
|
.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(
|
|
initialPortraitPanelVisible: ExpandedPlayerSheet.initialPortraitPanelVisibility(
|
|
settingsManager: appEnvironment.settingsManager
|
|
)
|
|
)
|
|
}
|
|
.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.openSettingsWindowRequest) { _, _ in
|
|
openWindow(id: "settings")
|
|
}
|
|
.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)
|
|
}
|
|
#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: - 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
|
|
/// Both render `NavigationRouter` state, so swapping between them keeps the
|
|
/// selection and every stack.
|
|
struct AdaptiveTabView: View {
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
|
|
let appEnvironment: AppEnvironment
|
|
|
|
|
|
/// 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 !usesSidebar {
|
|
CompactTabView(router: appEnvironment.navigationCoordinator.router)
|
|
.environment(appEnvironment.settingsManager)
|
|
} else {
|
|
UnifiedTabView(router: appEnvironment.navigationCoordinator.router)
|
|
.environment(appEnvironment.settingsManager)
|
|
}
|
|
|
|
// The tab bar layout on iOS 26.1+ shows the mini player as the tab
|
|
// bar's bottom accessory; the split-view sidebar layout has no tab bar.
|
|
if usesSidebar || !Self.usesTabBottomAccessory {
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var usesSidebar: Bool {
|
|
NavigationLayout.usesSidebar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
|
|
}
|
|
|
|
private static var usesTabBottomAccessory: Bool {
|
|
if #available(iOS 26.1, *) { true } else { false }
|
|
}
|
|
|
|
@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(.horizontal, 16)
|
|
// The tab bar layout has a bottom tab bar (49pt) to clear; the sidebar
|
|
// layout (sidebarAdaptable) keeps its tabs at the top, so only float above the edge.
|
|
.padding(.bottom, usesSidebar ? 12 : 49 + 8)
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
ContentView()
|
|
.appEnvironment(.preview)
|
|
}
|