mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
Yattee v2 rewrite
This commit is contained in:
378
Yattee/ContentView.swift
Normal file
378
Yattee/ContentView.swift
Normal file
@@ -0,0 +1,378 @@
|
||||
//
|
||||
// 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)
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in
|
||||
if appEnvironment.settingsManager.macPlayerMode.usesWindow {
|
||||
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
|
||||
}
|
||||
}
|
||||
.onChange(of: appEnvironment.navigationCoordinator.isPlayerExpanded) { _, isExpanded in
|
||||
if !isExpanded {
|
||||
ExpandedPlayerWindowManager.shared.hide()
|
||||
}
|
||||
}
|
||||
.onChange(of: appEnvironment.settingsManager.macPlayerMode) { oldMode, newMode in
|
||||
guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return }
|
||||
|
||||
if oldMode.usesWindow && newMode.usesWindow {
|
||||
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: newMode.isFloating)
|
||||
} else if oldMode.usesWindow && !newMode.usesWindow {
|
||||
ExpandedPlayerWindowManager.shared.hide(animated: false)
|
||||
} else if !oldMode.usesWindow && newMode.usesWindow {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
if appEnvironment.navigationCoordinator.isPlayerExpanded {
|
||||
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: {
|
||||
appEnvironment.navigationCoordinator.isPlayerExpanded &&
|
||||
!appEnvironment.settingsManager.macPlayerMode.usesWindow
|
||||
},
|
||||
set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 }
|
||||
)) {
|
||||
ExpandedPlayerSheet()
|
||||
.frame(minWidth: 640, minHeight: 480)
|
||||
.presentationSizing(.fitted)
|
||||
}
|
||||
#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)
|
||||
}
|
||||
#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
|
||||
|
||||
if hasActiveVideo && !isExpanded {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
MiniPlayerView()
|
||||
}
|
||||
// Add padding for tab bar
|
||||
.padding(.bottom, 49)
|
||||
// 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 {
|
||||
let appEnvironment: AppEnvironment
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
// Mini player sheet state (synced with NavigationCoordinator)
|
||||
@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 {
|
||||
let appEnvironment: AppEnvironment
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
// Mini player sheet state (synced with NavigationCoordinator)
|
||||
@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)
|
||||
}
|
||||
Reference in New Issue
Block a user