mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
In sheet (inline) mode the expanded player kept a fixed size and never reacted to the video's aspect ratio, unlike window/floating mode. Drive the sheet's backing NSWindow size from the video aspect ratio, reusing the same sizing math as the standalone window. - Extract shared sizing into ExpandedPlayerWindowManager.fittedPlayerSize and add fittedSheetSize for the sheet path. - Add SheetWindowResizer (NSViewRepresentable) + .sheetWindowSize modifier that resizes the hosting sheet window, since .presentationSizing(.fitted) only fits once at presentation. - Let the sheet content fill the window and paint the window background black so animated resizes stay gap-free with no white bars.
407 lines
17 KiB
Swift
407 lines
17 KiB
Swift
//
|
|
// 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 }
|
|
)) {
|
|
let size = expandedSheetSize(appEnvironment: appEnvironment)
|
|
ExpandedPlayerSheet()
|
|
// Only a floor — the content fills the sheet window so it tracks
|
|
// the window's animated resize with no gap. Do NOT pin an exact
|
|
// size here: a fixed frame snaps instantly while the window
|
|
// animates, exposing the window background as bars.
|
|
.frame(minWidth: 640, minHeight: 360)
|
|
.presentationSizing(.fitted)
|
|
// `.presentationSizing(.fitted)` only fits the sheet once, at
|
|
// presentation. Resize the backing window directly when the
|
|
// aspect-ratio-derived size changes so the sheet tracks the video.
|
|
.sheetWindowSize(size)
|
|
}
|
|
#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)
|
|
}
|
|
|
|
/// Size for the expanded-player sheet, derived from the current video aspect
|
|
/// ratio (when auto-resize is enabled) so the sheet re-fits like window mode.
|
|
/// Reading `videoAspectRatio` / `playerSheetAutoResize` here registers the
|
|
/// @Observable dependency that drives the re-fit.
|
|
private func expandedSheetSize(appEnvironment: AppEnvironment) -> CGSize {
|
|
let settings = appEnvironment.settingsManager
|
|
let aspect: Double
|
|
if settings.playerSheetAutoResize,
|
|
let ratio = appEnvironment.playerService.state.videoAspectRatio, ratio > 0 {
|
|
aspect = ratio
|
|
} else {
|
|
aspect = 16.0 / 9.0 // fixed default when auto-resize is off / not yet known
|
|
}
|
|
return ExpandedPlayerWindowManager.fittedSheetSize(for: aspect)
|
|
}
|
|
#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. In sheet mode the sheet covers the window, so hide it.
|
|
let usesWindow = appEnvironment.settingsManager.macPlayerMode.usesWindow
|
|
|
|
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)
|
|
}
|