mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Releasing the dismiss drag now shrinks the player window onto its video and flies it into the mini player's preview instead of sliding it off screen and showing the mini player afterwards. The mini player stays mounted under the expanded player and reports its preview frame, and the window is masked down to the video while its root view is transformed onto that frame (with the safe area the transform drops added back, so the content doesn't jump). The handoff of the shared MPV view is made seamless: a freshly made player container waits for its first layout before taking a view that is on screen elsewhere, sizes and redraws it there, and the window hides in that same frame. Resizing the render view draws the current frame into the recreated framebuffer right away instead of leaving it blank until the next tick. Falls back to the slide when no preview target is available (rotated session, video preview hidden, different window size).
293 lines
12 KiB
Swift
293 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,
|
|
toastScope: .main
|
|
)
|
|
.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
|
|
// (a gesture dismiss hides the window before un-expanding — not pending)
|
|
return nav.isPlayerExpanded && !nav.isPlayerWindowVisible && !nav.isPlayerSheetAnimating
|
|
&& !nav.isPlayerCollapsing && !nav.isPlayerMorphingToMini
|
|
}
|
|
|
|
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 insets it
|
|
// into its detail column.
|
|
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
|
|
|
|
// Stays mounted under the expanded player: the dismiss gesture morphs
|
|
// the video into its preview, so it must already be in place
|
|
if hasActiveVideo {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
MiniPlayerView()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
// Clear the bottom tab bar (49pt)
|
|
.padding(.bottom, 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)
|
|
}
|