Files
yattee/Yattee/ContentView.swift
Arkadiusz Fal ecb6a9dd7a Replace macOS inline player sheet with full-window overlay
When "Play in a separate window" is disabled, the expanded player was a
native sheet attached to the main window. AppKit refuses fullscreen with
an attached sheet, so pressing F swapped the sheet for an in-window
overlay before fullscreening — a visible two-step transition.

Present the inline player as the full-window overlay from the start, so
fullscreen is just the window's native toggle. This removes the whole
sheet-swap machinery (toggleSheetFullScreen with its detach timing,
SheetWindowResizer, sheet sizing helpers).

- beginInlineOverlay/endInlineOverlay hide/restore the main window
  toolbar and mirror the window's fullscreen state into the renamed
  isMacInlinePlayerFullScreen flag via NSWindow notifications, so exits
  through Esc/green button/Mission Control stay in sync
- Collapsing exits fullscreen only if it was entered for the video
- Esc in the windowed overlay collapses to the mini bar via a new
  onCollapse callback (replacing the sheet's default Esc-dismiss)
- ContentView registers its hosting window (MainContentWindowReader) so
  the overlay targets the right window even when the setting is toggled
  while the Settings window has focus
2026-07-03 21:57:55 +02:00

418 lines
18 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)
// 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)
}