mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
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
1724 lines
84 KiB
Swift
1724 lines
84 KiB
Swift
//
|
|
// ExpandedPlayerSheet+Layouts.swift
|
|
// Yattee
|
|
//
|
|
// Layout views for the expanded player sheet (portrait, widescreen, player areas).
|
|
//
|
|
|
|
import SwiftUI
|
|
import NukeUI
|
|
|
|
#if os(iOS) || os(macOS) || os(tvOS)
|
|
|
|
extension ExpandedPlayerSheet {
|
|
// MARK: - Layout Constants
|
|
|
|
/// Minimum width required to show widescreen layout with floating panel.
|
|
static let minWidthForWidescreen: CGFloat = 700
|
|
|
|
// MARK: - Layout Detection
|
|
|
|
/// Check if widescreen layout should be used based on available size.
|
|
func isWideScreenLayout(size: CGSize) -> Bool {
|
|
#if os(iOS)
|
|
let isLandscape = size.width > size.height
|
|
return isLandscape // All landscape orientations use widescreen
|
|
#elseif os(macOS)
|
|
// macOS: Always use widescreen layout
|
|
return true
|
|
#else
|
|
return false // tvOS uses different layout
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Download Action Handler
|
|
|
|
#if !os(tvOS)
|
|
/// Handles download button action based on current state.
|
|
func handleDownloadAction(for video: Video) {
|
|
let isDownloaded = downloadManager?.isDownloaded(video.id) ?? false
|
|
let download = downloadManager?.download(for: video.id)
|
|
let isDownloading = download?.status == .downloading || download?.status == .queued
|
|
|
|
if isDownloading, let download {
|
|
Task {
|
|
await downloadManager?.cancel(download)
|
|
}
|
|
} else if isDownloaded {
|
|
showingDeleteDownloadAlert = true
|
|
} else {
|
|
startDownload(for: video)
|
|
}
|
|
}
|
|
|
|
/// Starts a download either automatically or by showing the quality sheet.
|
|
private func startDownload(for video: Video) {
|
|
guard let appEnvironment else {
|
|
showingDownloadSheet = true
|
|
return
|
|
}
|
|
|
|
// Media source videos (SMB/WebDAV/local) use direct file URLs - no API call needed
|
|
if video.isFromMediaSource {
|
|
Task {
|
|
do {
|
|
try await appEnvironment.downloadManager.autoEnqueueMediaSource(
|
|
video,
|
|
mediaSourcesManager: appEnvironment.mediaSourcesManager,
|
|
webDAVClient: appEnvironment.webDAVClient,
|
|
smbClient: appEnvironment.smbClient
|
|
)
|
|
} catch {
|
|
appEnvironment.toastManager.show(
|
|
category: .error,
|
|
title: String(localized: "download.error.title"),
|
|
subtitle: error.localizedDescription,
|
|
icon: "exclamationmark.triangle",
|
|
iconColor: .red
|
|
)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
let downloadSettings = appEnvironment.downloadSettings
|
|
|
|
// Check if auto-download mode
|
|
if downloadSettings.preferredDownloadQuality != .ask,
|
|
let instance = appEnvironment.instancesManager.instance(for: video) {
|
|
Task {
|
|
do {
|
|
try await appEnvironment.downloadManager.autoEnqueue(
|
|
video,
|
|
preferredQuality: downloadSettings.preferredDownloadQuality,
|
|
preferredAudioLanguage: appEnvironment.settingsManager.preferredAudioLanguage,
|
|
preferredSubtitlesLanguage: appEnvironment.settingsManager.preferredSubtitlesLanguage,
|
|
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload,
|
|
contentService: appEnvironment.contentService,
|
|
instance: instance
|
|
)
|
|
} catch {
|
|
appEnvironment.toastManager.show(
|
|
category: .error,
|
|
title: String(localized: "download.error.title"),
|
|
subtitle: error.localizedDescription,
|
|
icon: "exclamationmark.triangle",
|
|
iconColor: .red
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
showingDownloadSheet = true
|
|
}
|
|
}
|
|
#else
|
|
/// No-op on tvOS.
|
|
func handleDownloadAction(for video: Video) {}
|
|
#endif
|
|
|
|
// MARK: - Standard/Portrait Layout
|
|
|
|
/// Standard player content for portrait mode with video at top and panel below.
|
|
@ViewBuilder
|
|
func standardPlayerContent(geometry: GeometryProxy) -> some View {
|
|
// Ensure valid aspect ratio (avoid division by zero)
|
|
let rawAspectRatio = playerState?.displayAspectRatio ?? (16.0 / 9.0)
|
|
let aspectRatio = rawAspectRatio > 0 ? rawAspectRatio : (16.0 / 9.0)
|
|
// Content area size (excludes safe areas)
|
|
let contentHeight = max(1, geometry.size.height)
|
|
let contentWidth = max(1, geometry.size.width)
|
|
// Full screen size including safe areas (for portrait fullscreen)
|
|
let safeAreaInsets = geometry.safeAreaInsets
|
|
let fullScreenHeight = max(1, contentHeight + safeAreaInsets.top + safeAreaInsets.bottom)
|
|
// Use content size for layout, full screen size for portrait fullscreen controls
|
|
let screenHeight = contentHeight
|
|
let screenWidth = contentWidth
|
|
|
|
// Reserve minimum space for panel when pinned
|
|
// Dynamic height based on description content
|
|
// Only use compact panel when we've confirmed video has no description
|
|
// (detailsState == .loaded and hasDescription == false)
|
|
// During idle/loading states, use larger panel to prevent jump when description loads
|
|
let hasDescription = !(playerState?.currentVideo?.description ?? "").isEmpty
|
|
let videoDetailsState = playerState?.videoDetailsState ?? .idle
|
|
let shouldUseCompactPanel = videoDetailsState == .loaded && !hasDescription
|
|
|
|
// Check if expanded comments pill will be visible (needs extra panel height)
|
|
let pillSettings = playerControlsLayout.effectivePlayerPillSettings
|
|
let hasCommentsPill = playerState?.commentsState == .loaded &&
|
|
!(playerState?.comments.isEmpty ?? true) &&
|
|
pillSettings.shouldShowCommentsPill
|
|
let hasExpandedCommentsPill = hasCommentsPill &&
|
|
pillSettings.visibility.isVisible(isWideLayout: false) &&
|
|
!pillSettings.buttons.isEmpty &&
|
|
!pillSettings.isCommentsPillAlwaysCollapsed
|
|
|
|
let minPanelHeight: CGFloat = useCompactPanel ? 150 : 200
|
|
// When compact panel but expanded comments pill visible, need taller panel (~250pt)
|
|
let maxPanelHeight: CGFloat = useCompactPanel ? (hasExpandedCommentsPill ? 250 : 190) : .infinity
|
|
|
|
let maxVideoHeight = max(1, screenHeight - minPanelHeight)
|
|
|
|
// Calculate fit size (aspect-fit within screen bounds, but capped to show panel)
|
|
let fitHeightFromWidth = screenWidth / aspectRatio
|
|
let fitWidthFromHeight = screenHeight * aspectRatio
|
|
let uncappedFitHeight: CGFloat = fitHeightFromWidth > screenHeight ? screenHeight : fitHeightFromWidth
|
|
let uncappedFitWidth: CGFloat = fitHeightFromWidth > screenHeight ? fitWidthFromHeight : screenWidth
|
|
|
|
// Apply max height constraint to leave space for panel
|
|
let fitHeight = max(1, min(uncappedFitHeight, maxVideoHeight))
|
|
let fitWidth = max(1, fitHeight < uncappedFitHeight ? fitHeight * aspectRatio : uncappedFitWidth)
|
|
|
|
// Calculate fullscreen fit size (aspect-fit within full screen including safe areas)
|
|
let fullscreenFitHeightFromWidth = screenWidth / aspectRatio
|
|
let fullscreenFitHeight: CGFloat = max(1, fullscreenFitHeightFromWidth > fullScreenHeight ? fullScreenHeight : fullscreenFitHeightFromWidth)
|
|
|
|
// Calculate fill size (aspect-fill to cover screen)
|
|
let fillHeight: CGFloat = max(1, fitHeightFromWidth >= screenHeight ? fitHeightFromWidth : screenHeight)
|
|
let fillWidth: CGFloat = max(1, fitHeightFromWidth >= screenHeight ? screenWidth : fitWidthFromHeight)
|
|
|
|
#if os(iOS)
|
|
// Use UIKit pinch gesture panscan from NavigationCoordinator
|
|
let panscan = !isPortraitPanelVisible ? (navigationCoordinator?.pinchPanscan ?? 0.0) : 0.0
|
|
#else
|
|
let panscan = 0.0
|
|
#endif
|
|
|
|
// Video layer uses panscan-interpolated size, controls stay at fit size
|
|
let videoHeight = max(1, fitHeight + (fillHeight - fitHeight) * panscan)
|
|
let videoWidth = max(1, fitWidth + (fillWidth - fitWidth) * panscan)
|
|
|
|
// In fullscreen, offset the player up by top safe area to align with screen top
|
|
let fullscreenPlayerOffset: CGFloat = !isPortraitPanelVisible ? -safeAreaInsets.top : 0
|
|
|
|
let isDismissing = navigationCoordinator?.isPlayerDismissGestureActive == true
|
|
|
|
// Calculate player height - animates between fit and fullscreen
|
|
// Use full screen height when panel is hidden for immersive controls
|
|
let playerHeight = !isPortraitPanelVisible ? fullScreenHeight : fitHeight
|
|
|
|
// Panel height when pinned (capped by maxPanelHeight for widescreen videos without description)
|
|
let naturalPanelHeight = screenHeight - fitHeight
|
|
let pinnedPanelHeight = min(naturalPanelHeight, maxPanelHeight)
|
|
let pillsOverlayOpacity: CGFloat = {
|
|
guard isPanelDragging, panelDragOffset > 0 else { return 1 }
|
|
let fadeDistance: CGFloat = 48
|
|
let progress = min(1, panelDragOffset / fadeDistance)
|
|
return 1 - progress
|
|
}()
|
|
|
|
// Video area height (space above panel) - may be larger than fitHeight when panel is capped
|
|
let videoAreaHeight = screenHeight - pinnedPanelHeight
|
|
|
|
// Calculate video Y position
|
|
// Note: Coordinate system starts at top of content area (after safe area insets)
|
|
// so we don't add safeAreaInsets.top for positioning within content area
|
|
//
|
|
// videoYOffset is a @State that we animate explicitly for smooth drag release animation.
|
|
// Base positions are computed, then videoYOffset is added.
|
|
let topY = videoAreaHeight / 2 // Video centered in video area (above panel)
|
|
let centerY = screenHeight / 2 // Video centered when panel hidden
|
|
|
|
let videoY: CGFloat = {
|
|
if isPortraitPanelVisible {
|
|
// Panel is visible - base position is at top, offset is animated
|
|
return topY + videoYOffset
|
|
} else {
|
|
// Panel is hidden - interpolate based on reveal offset (swipe up to reveal)
|
|
if panelRevealOffset < 0 {
|
|
// Revealing panel - move video towards top
|
|
let revealProgress = min(1.0, -panelRevealOffset / pinnedPanelHeight)
|
|
return centerY - (centerY - topY) * revealProgress
|
|
}
|
|
// Fullscreen: fill entire screen (need to offset for safe area)
|
|
return fullScreenHeight / 2 + fullscreenPlayerOffset
|
|
}
|
|
}()
|
|
|
|
// Create player view with stable identity for smooth animations (no controls - rendered separately)
|
|
let playerView = portraitPlayerArea(
|
|
fitWidth: fitWidth,
|
|
fitHeight: fitHeight,
|
|
videoWidth: videoWidth,
|
|
videoHeight: videoHeight,
|
|
screenWidth: screenWidth,
|
|
screenHeight: fullScreenHeight,
|
|
isFullscreen: !isPortraitPanelVisible,
|
|
showControls: false,
|
|
activeLayout: playerControlsLayout
|
|
)
|
|
.frame(width: screenWidth, height: playerHeight)
|
|
.clipped()
|
|
// Black background hides during dismiss gesture
|
|
.background(isDismissing && isPortraitPanelVisible ? Color.clear : (isDismissing || !isPortraitPanelVisible ? Color.clear : Color.black))
|
|
.geometryGroup()
|
|
.id("player") // Stable identity for aspect ratio animations
|
|
|
|
// Controls layer - rendered separately
|
|
// When panel visible: sized to video area, positioned at top
|
|
// When fullscreen: sized and positioned exactly like playerView to align perfectly
|
|
|
|
// During drag, expand controls layer to follow the panel
|
|
// This keeps the bottom bar close to the panel as it drags down
|
|
// Note: We check panelDragOffset > 0 without isPanelDragging so the animation
|
|
// continues smoothly when drag ends (panelDragOffset animates back to 0)
|
|
let draggedControlsHeight: CGFloat = {
|
|
if isPortraitPanelVisible && panelDragOffset > 0 {
|
|
return videoAreaHeight + panelDragOffset
|
|
}
|
|
return isPortraitPanelVisible ? videoAreaHeight : fullScreenHeight
|
|
}()
|
|
|
|
let controlsLayer = portraitControlsOverlay(
|
|
screenWidth: screenWidth,
|
|
// Both modes use their respective heights
|
|
screenHeight: draggedControlsHeight,
|
|
fitHeight: fitHeight,
|
|
isFullscreen: !isPortraitPanelVisible,
|
|
forceHidden: hideControlsDuringTransition,
|
|
onTogglePanel: { toggleFloatingPanelVisibility() },
|
|
isPanelVisible: isPortraitPanelVisible,
|
|
isPanelPinned: isPortraitPanelVisible,
|
|
// Video area bounds for positioning center controls, sliders, bottom bar
|
|
// When panel visible: video fills the controls area (0 to fitHeight)
|
|
// When fullscreen: video centered on full screen
|
|
// During drag: expand videoAreaHeight so bottom bar moves down with panel
|
|
videoAreaTop: isPortraitPanelVisible ? 0 : (fullScreenHeight - fullscreenFitHeight) / 2,
|
|
videoAreaHeight: isPortraitPanelVisible ? draggedControlsHeight : fullscreenFitHeight,
|
|
// Pass stable fitHeight for slider sizing (doesn't change during drag)
|
|
// Panel visible or dragging: use fitHeight for stable sizing
|
|
// Fullscreen (after drag completes): pass nil so sliders use geometry.size.height
|
|
videoFitHeight: (isPortraitPanelVisible || isPanelDragging) ? fitHeight : nil,
|
|
activeLayout: playerControlsLayout
|
|
)
|
|
.frame(width: screenWidth, height: draggedControlsHeight)
|
|
|
|
ZStack {
|
|
// Black background - clear during dismiss to avoid black bar in safe area
|
|
(isDismissing ? Color.clear : Color.black)
|
|
.ignoresSafeArea(.all)
|
|
|
|
// Player positioned based on panel state
|
|
playerView
|
|
.position(x: screenWidth / 2, y: videoY)
|
|
// Animate video position when not dragging (matches panel animation behavior)
|
|
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: videoYOffset)
|
|
.animation(panelRevealOffset != 0 ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: isPortraitPanelVisible)
|
|
|
|
// Controls layer - positioned based on panel state
|
|
// When panel visible: positioned to cover from top down to panel
|
|
// When fullscreen: positioned exactly like playerView so they align
|
|
Group {
|
|
if isPortraitPanelVisible {
|
|
controlsLayer
|
|
.position(x: screenWidth / 2, y: draggedControlsHeight / 2)
|
|
// Animate controls position when not dragging (matches panel animation)
|
|
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: panelDragOffset)
|
|
} else {
|
|
// Position exactly like playerView - at videoY with same offset
|
|
// This ensures controls y=0 aligns with screen y=0
|
|
controlsLayer
|
|
.position(x: screenWidth / 2, y: fullScreenHeight / 2 + fullscreenPlayerOffset)
|
|
}
|
|
}
|
|
// Reveal gesture - swipe up to show panel when hidden (simultaneous so taps still work for controls)
|
|
#if !os(tvOS)
|
|
.simultaneousGesture(
|
|
!isPortraitPanelVisible ?
|
|
DragGesture()
|
|
.onChanged { value in
|
|
// Skip if user is adjusting sliders
|
|
guard navigationCoordinator?.isAdjustingPlayerSliders != true else { return }
|
|
|
|
// Dragging up = negative translation
|
|
if value.translation.height < 0 {
|
|
hideControlsDuringTransition = true
|
|
panelRevealOffset = value.translation.height
|
|
}
|
|
}
|
|
.onEnded { value in
|
|
// Skip if user is adjusting sliders
|
|
guard navigationCoordinator?.isAdjustingPlayerSliders != true else {
|
|
// Reset any partial reveal state
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
|
panelRevealOffset = 0
|
|
}
|
|
hideControlsDuringTransition = false
|
|
return
|
|
}
|
|
|
|
let threshold: CGFloat = -80
|
|
let currentRevealOffset = panelRevealOffset
|
|
|
|
if value.translation.height < threshold || value.predictedEndTranslation.height < threshold * 2 {
|
|
// Revealing panel - single panel approach
|
|
// Position panel at current location using panelDragOffset before switching to visible
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = true
|
|
withTransaction(transaction) {
|
|
isPanelDragging = true
|
|
navigationCoordinator?.isPanelDragging = true
|
|
// Transfer current visual position to panelDragOffset
|
|
// Before: offset = pinnedPanelHeight + safeAreaInsets.bottom + panelRevealOffset
|
|
// After: offset = panelDragOffset (since isPortraitPanelVisible will be true)
|
|
panelDragOffset = pinnedPanelHeight + safeAreaInsets.bottom + currentRevealOffset
|
|
panelRevealOffset = 0
|
|
isPortraitPanelVisible = true
|
|
navigationCoordinator?.isPortraitPanelVisible = true
|
|
// Match video position from reveal formula
|
|
let revealProgress = min(1.0, -currentRevealOffset / pinnedPanelHeight)
|
|
let maxVideoOffset = centerY - topY
|
|
videoYOffset = maxVideoOffset * (1.0 - revealProgress)
|
|
}
|
|
|
|
// Now animate to final position
|
|
isPanelDragging = false
|
|
panelDragOffset = 0
|
|
videoYOffset = 0
|
|
|
|
// Delay NavigationCoordinator update until after position animation starts
|
|
// This keeps pill animations suppressed during the transition
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
navigationCoordinator?.isPanelDragging = false
|
|
}
|
|
hideControlsDuringTransition = false
|
|
} else {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
|
panelRevealOffset = 0
|
|
}
|
|
hideControlsDuringTransition = false
|
|
}
|
|
}
|
|
: nil
|
|
)
|
|
#endif
|
|
|
|
// Pinned panel (fills space below video) - iOS only
|
|
#if os(iOS)
|
|
// Always rendered; use offset to hide when not visible
|
|
if isPortraitPanelVisible || panelRevealOffset != 0 {
|
|
if let video = playerState?.currentVideo {
|
|
VStack(spacing: 0) {
|
|
// Spacer shrinks as panel expands (follows drag)
|
|
// panelExpandOffset is negative when dragging up
|
|
// Use videoAreaHeight (not fitHeight) to account for capped panel height
|
|
Spacer()
|
|
.frame(height: {
|
|
if isPanelExpanded {
|
|
// When expanded, spacer grows as user drags down to collapse
|
|
return min(videoAreaHeight, panelExpandOffset)
|
|
} else {
|
|
// When normal, spacer shrinks as user drags up
|
|
return max(0, videoAreaHeight + panelExpandOffset)
|
|
}
|
|
}())
|
|
|
|
PortraitDetailsPanel(
|
|
onChannelTap: video.author.hasRealChannelInfo ? {
|
|
navigationCoordinator?.navigateToChannel(for: video, collapsePlayer: true)
|
|
} : nil,
|
|
playerControlsLayout: playerControlsLayout,
|
|
onFullscreen: { [self] in toggleFullscreen() },
|
|
pillsOverlayOpacity: pillsOverlayOpacity,
|
|
onDragChanged: { [self] offset in
|
|
// Set drag flags only on transition to avoid 120/sec @Observable writes
|
|
if !isPanelDragging {
|
|
isPanelDragging = true
|
|
navigationCoordinator?.isPanelDragging = true
|
|
}
|
|
// Use transaction to disable animations during drag
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = true
|
|
withTransaction(transaction) {
|
|
if isPanelExpanded {
|
|
// When expanded, only track drag for collapse
|
|
// Positive offset = dragging down = collapse
|
|
panelExpandOffset = max(0, offset)
|
|
} else if offset < 0 {
|
|
// Dragging UP - expand panel
|
|
panelExpandOffset = offset // Negative value
|
|
panelDragOffset = 0
|
|
videoYOffset = 0 // Video stays in place
|
|
} else {
|
|
// Dragging DOWN - existing dismiss logic
|
|
panelDragOffset = offset
|
|
panelExpandOffset = 0
|
|
// Calculate video offset: interpolate from 0 to (centerY - topY) based on drag
|
|
let maxOffset = centerY - topY
|
|
let dragProgress = min(1.0, offset / pinnedPanelHeight)
|
|
videoYOffset = maxOffset * dragProgress
|
|
}
|
|
}
|
|
},
|
|
onDragEnded: { [self] offset, predictedOffset in
|
|
let dismissThreshold: CGFloat = 80
|
|
let expandThreshold: CGFloat = -80 // Upward threshold
|
|
let targetOffset = pinnedPanelHeight + safeAreaInsets.bottom
|
|
let maxVideoOffset = centerY - topY
|
|
|
|
// Setting isPanelDragging = false activates the .animation modifiers
|
|
isPanelDragging = false
|
|
navigationCoordinator?.isPanelDragging = false
|
|
|
|
if isPanelExpanded {
|
|
// In expanded state, drag down to collapse
|
|
let collapseThreshold: CGFloat = 80
|
|
if offset > collapseThreshold || predictedOffset > collapseThreshold * 2 {
|
|
// Collapse back to normal
|
|
isPanelExpanded = false
|
|
panelExpandOffset = 0
|
|
} else {
|
|
// Snap back to expanded
|
|
panelExpandOffset = 0
|
|
}
|
|
} else if offset < expandThreshold || predictedOffset < expandThreshold * 2 {
|
|
// Expand panel to fullscreen
|
|
isPanelExpanded = true
|
|
panelExpandOffset = 0
|
|
panelDragOffset = 0
|
|
videoYOffset = 0
|
|
} else if offset > dismissThreshold || predictedOffset > dismissThreshold * 2 {
|
|
// Dismissing - animate panel sliding off and video to center
|
|
panelDragOffset = targetOffset
|
|
videoYOffset = maxVideoOffset
|
|
// Hide after animation completes - disable animations for cleanup
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = true
|
|
withTransaction(transaction) {
|
|
isPortraitPanelVisible = false
|
|
navigationCoordinator?.isPortraitPanelVisible = false
|
|
isCommentsExpanded = false
|
|
navigationCoordinator?.isCommentsExpanded = false
|
|
navigationCoordinator?.commentsFrame = .zero
|
|
panelDragOffset = 0
|
|
videoYOffset = 0 // Reset offset (centerY is now the base)
|
|
}
|
|
}
|
|
} else {
|
|
// Snap back - animate panel and video back to rest position
|
|
panelDragOffset = 0
|
|
panelExpandOffset = 0
|
|
videoYOffset = 0
|
|
}
|
|
},
|
|
onDragCancelled: { [self] in
|
|
// Reset drag state on cancellation - snap back to rest position
|
|
isPanelDragging = false
|
|
navigationCoordinator?.isPanelDragging = false
|
|
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) {
|
|
panelDragOffset = 0
|
|
panelExpandOffset = 0
|
|
videoYOffset = 0
|
|
}
|
|
}
|
|
)
|
|
.frame(height: {
|
|
let normalHeight = pinnedPanelHeight + safeAreaInsets.bottom
|
|
let expandedHeight = screenHeight + safeAreaInsets.bottom
|
|
if isPanelExpanded {
|
|
// When expanded, shrink as user drags down to collapse
|
|
return max(normalHeight, expandedHeight - panelExpandOffset)
|
|
} else {
|
|
// When normal, grow as user drags up (panelExpandOffset is negative)
|
|
return min(expandedHeight, normalHeight - panelExpandOffset)
|
|
}
|
|
}())
|
|
.offset(y: panelDragOffset) // Only use dismiss offset, expand is handled by height
|
|
// When hidden: offset panel below screen; during reveal: panelRevealOffset (negative) brings it up
|
|
.offset(y: !isPortraitPanelVisible ? (pinnedPanelHeight + safeAreaInsets.bottom + panelRevealOffset) : 0)
|
|
.opacity(!isPortraitPanelVisible ? min(1, -panelRevealOffset / 80) : 1)
|
|
.allowsHitTesting(isPortraitPanelVisible)
|
|
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: panelDragOffset)
|
|
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: panelExpandOffset)
|
|
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: isPanelExpanded)
|
|
.background(
|
|
GeometryReader { panelGeometry in
|
|
Color.clear
|
|
.onAppear {
|
|
navigationCoordinator?.portraitPanelFrame = panelGeometry.frame(in: .global)
|
|
}
|
|
.onChange(of: panelGeometry.frame(in: .global)) { _, newFrame in
|
|
// Skip updates during drag to avoid 120/sec coordinator writes
|
|
guard !isPanelDragging else { return }
|
|
navigationCoordinator?.portraitPanelFrame = newFrame
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.ignoresSafeArea(edges: .bottom) // Only ignore bottom safe area, preserve top
|
|
}
|
|
}
|
|
#endif
|
|
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.background(!isPortraitPanelVisible ? Color.black : Color.clear)
|
|
// Only animate when aspect ratio changes, not when geometry changes during sheet presentation
|
|
.animation(.easeInOut(duration: 0.3), value: aspectRatio)
|
|
.animation(.easeInOut(duration: 0.3), value: isPortraitPanelVisible)
|
|
.animation(.easeInOut(duration: 0.3), value: useCompactPanel)
|
|
// Animate panel height change when comments load (expanded comments pill becomes visible)
|
|
.animation(.easeInOut(duration: 0.3), value: playerState?.commentsState)
|
|
.onAppear {
|
|
currentPlayerHeight = fitHeight
|
|
// Sync panel visibility with NavigationCoordinator (preserve existing state)
|
|
navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible
|
|
// Initialize compact panel state without animation (prevent animation on sheet re-expand)
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = true
|
|
withTransaction(transaction) {
|
|
useCompactPanel = shouldUseCompactPanel
|
|
}
|
|
}
|
|
.onChange(of: fitHeight) { _, newHeight in
|
|
currentPlayerHeight = newHeight
|
|
}
|
|
// Note: We intentionally do NOT clear portraitPanelFrame when panel hides.
|
|
// The GeometryReader will track the off-screen position naturally.
|
|
// Clearing to .zero causes race conditions where the frame stays .zero
|
|
// after reveal, allowing sheet dismiss gesture to incorrectly fire.
|
|
.onChange(of: isPortraitPanelVisible) { _, isVisible in
|
|
if !isVisible {
|
|
// Clear panel frame when hidden
|
|
navigationCoordinator?.portraitPanelFrame = .zero
|
|
// Reset panel expansion state
|
|
isPanelExpanded = false
|
|
panelExpandOffset = 0
|
|
}
|
|
}
|
|
.onChange(of: playerState?.videoDetailsState) { _, newState in
|
|
// Update compact panel state with animation when video details finish loading
|
|
// When state goes to .idle (video changing), this resets to non-compact
|
|
// When state goes to .loaded with no description, this enables compact
|
|
let hasDescription = !(playerState?.currentVideo?.description ?? "").isEmpty
|
|
let shouldUseCompact = newState == .loaded && !hasDescription
|
|
if shouldUseCompact != useCompactPanel {
|
|
useCompactPanel = shouldUseCompact
|
|
}
|
|
}
|
|
.onChange(of: playerState?.currentVideo?.description) { _, newDesc in
|
|
// Description may load after videoDetailsState changes to .loaded
|
|
// Update compact panel state when description changes
|
|
let videoDetailsState = playerState?.videoDetailsState ?? .idle
|
|
let hasDescription = !(newDesc ?? "").isEmpty
|
|
let shouldUseCompact = videoDetailsState == .loaded && !hasDescription
|
|
if shouldUseCompact != useCompactPanel {
|
|
useCompactPanel = shouldUseCompact
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Toggle floating panel visibility (for tall videos only)
|
|
private func toggleFloatingPanelVisibility() {
|
|
let newValue = !isPortraitPanelVisible
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
isPortraitPanelVisible = newValue
|
|
}
|
|
navigationCoordinator?.isPortraitPanelVisible = newValue
|
|
}
|
|
|
|
/// Hide floating panel with animation
|
|
private func hideFloatingPanel() {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
isPortraitPanelVisible = false
|
|
}
|
|
navigationCoordinator?.isPortraitPanelVisible = false
|
|
|
|
// Reset main window transform when hiding panel to fix safe area clipping (iOS only)
|
|
#if os(iOS)
|
|
ExpandedPlayerWindowManager.shared.resetMainWindowImmediate()
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Portrait Player Area
|
|
|
|
/// Player area for portrait layout that separates video (panscan-scaled) from controls.
|
|
/// In fullscreen: controls cover full screen, video scales with panscan.
|
|
/// In normal mode: both video and controls at fit size.
|
|
@ViewBuilder
|
|
func portraitPlayerArea(
|
|
fitWidth: CGFloat,
|
|
fitHeight: CGFloat,
|
|
videoWidth: CGFloat,
|
|
videoHeight: CGFloat,
|
|
screenWidth: CGFloat,
|
|
screenHeight: CGFloat,
|
|
isFullscreen: Bool,
|
|
showControls: Bool = true,
|
|
activeLayout: PlayerControlsLayout = .default
|
|
) -> some View {
|
|
let info = playbackInfo
|
|
|
|
// Ensure valid dimensions (guard against negative or zero values)
|
|
let safeVideoWidth = max(1, videoWidth)
|
|
let safeVideoHeight = max(1, videoHeight)
|
|
let safeFitHeight = max(1, fitHeight)
|
|
let safeScreenWidth = max(1, screenWidth)
|
|
let safeScreenHeight = max(1, screenHeight)
|
|
|
|
// Container size for the overall player area (video + black bars)
|
|
let containerHeight = isFullscreen ? safeScreenHeight : safeFitHeight
|
|
|
|
// Controls ALWAYS use full screen size to prevent layout shifts during fullscreen toggle
|
|
// In non-fullscreen, we offset them so they appear centered in the player area
|
|
// but their internal layout stays constant
|
|
let controlsWidth = safeScreenWidth
|
|
let controlsHeight = safeScreenHeight
|
|
|
|
// Calculate vertical offset to center controls in player area when not fullscreen
|
|
// When fullscreen, offset is 0 (controls fill screen)
|
|
// When not fullscreen, offset controls up so they center on the player area
|
|
let controlsVerticalOffset = isFullscreen ? 0.0 : (safeFitHeight - safeScreenHeight) / 2
|
|
|
|
ZStack {
|
|
// Black background - fills full screen width to prevent content leaking on sides
|
|
// Hidden during PiP (so system placeholder is visible) UNLESS dismissing (to prevent content leak)
|
|
let isPiPActive = playerState?.pipState == .active
|
|
let isDismissing = appEnvironment?.navigationCoordinator.isPlayerDismissGestureActive == true
|
|
if !isPiPActive || isDismissing {
|
|
Color.black
|
|
.frame(width: safeScreenWidth, height: containerHeight)
|
|
}
|
|
|
|
// Video layer - scaled with panscan, centered
|
|
ZStack {
|
|
// Thumbnail layer - fades in on appear, fades out when player is ready and buffer is loaded
|
|
// During transition, use frozen URL to prevent old thumbnail flash
|
|
if let video = playerState?.currentVideo {
|
|
let isFirstFrameReady = playerState?.isFirstFrameReady ?? false
|
|
let isBufferReady = playerState?.isBufferReady ?? false
|
|
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
|
|
let showThumbnail = !info.hasBackend || !isFirstFrameReady || !isBufferReady || isAudioOnly
|
|
// Use frozen URL during transition, otherwise current video's thumbnail
|
|
let thumbnailURL = isThumbnailFrozen ? displayedThumbnailURL : video.bestThumbnail?.url
|
|
|
|
// Hidden loader - loads image into @State (invisible)
|
|
LazyImage(url: thumbnailURL) { state in
|
|
Color.clear
|
|
.onChange(of: state.image) { _, newImage in
|
|
if let newImage { displayedThumbnailImage = newImage }
|
|
}
|
|
.onAppear {
|
|
if let image = state.image { displayedThumbnailImage = image }
|
|
}
|
|
}
|
|
.frame(width: 1, height: 1)
|
|
.opacity(0)
|
|
|
|
// Stable display - @State image never flashes during re-renders
|
|
Group {
|
|
if let thumbnailImage = displayedThumbnailImage {
|
|
thumbnailImage.resizable().aspectRatio(contentMode: .fill)
|
|
} else {
|
|
Color.black
|
|
}
|
|
}
|
|
.frame(width: safeVideoWidth, height: safeVideoHeight)
|
|
.opacity(showThumbnail ? 1 : 0)
|
|
.animation(.easeInOut(duration: 0.2), value: showThumbnail)
|
|
.transition(.opacity.animation(.easeInOut(duration: 0.3)))
|
|
.animation(.easeInOut(duration: 0.3), value: video.id)
|
|
}
|
|
|
|
// Video player layer - fades in when ready
|
|
if let backend = playerService?.currentBackend as? MPVBackend,
|
|
let playerState,
|
|
let playerService,
|
|
info.hasBackend {
|
|
let isAudioOnlyStream = playerState.currentStream?.isAudioOnly == true
|
|
MPVVideoView(
|
|
backend: backend,
|
|
playerState: playerState,
|
|
playerService: playerService,
|
|
showsControls: false, // Controls rendered separately
|
|
showsDebugOverlay: false // Debug overlay rendered at sheet level
|
|
)
|
|
.frame(width: safeVideoWidth, height: safeVideoHeight)
|
|
// Only show video when both first frame is ready AND buffer is ready
|
|
// This prevents showing a frozen frame before playback can start smoothly
|
|
// Hide for audio-only streams - show thumbnail instead
|
|
.opacity(info.hasBackend && playerState.isFirstFrameReady && playerState.isBufferReady && !isAudioOnlyStream ? 1 : 0)
|
|
.animation(.easeInOut(duration: 0.2), value: info.hasBackend && playerState.isFirstFrameReady && playerState.isBufferReady && !isAudioOnlyStream)
|
|
}
|
|
|
|
// Fallback when no video is set
|
|
if playerState?.currentVideo == nil {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.controlSize(.large)
|
|
}
|
|
}
|
|
.frame(width: safeVideoWidth, height: safeVideoHeight)
|
|
|
|
// Loading overlay - covers full container width (including letterbox bars)
|
|
if playerState?.currentVideo != nil {
|
|
let isWaitingForBuffer = !(playerState?.isBufferReady ?? true)
|
|
thumbnailOverlayContent(
|
|
isIdle: info.isIdle,
|
|
isEnded: info.isEnded,
|
|
isFailed: info.isFailed,
|
|
isLoading: info.isLoading || isWaitingForBuffer
|
|
)
|
|
.frame(width: safeScreenWidth, height: containerHeight)
|
|
}
|
|
|
|
// Controls layer - covers full screen in fullscreen mode, fit size otherwise
|
|
// Only render here if showControls is true (controls will be rendered externally otherwise)
|
|
#if os(iOS)
|
|
if showControls,
|
|
let backend = playerService?.currentBackend,
|
|
backend.backendType == .mpv,
|
|
let playerState,
|
|
let playerService,
|
|
playerState.pipState != .active && !playerState.showDebugOverlay {
|
|
PlayerControlsView(
|
|
playerState: playerState,
|
|
onPlayPause: {
|
|
playerService.togglePlayPause()
|
|
},
|
|
onSeek: { time in
|
|
await playerService.seek(to: time)
|
|
},
|
|
onSeekForward: { seconds in
|
|
playerService.seekForward(by: seconds)
|
|
},
|
|
onSeekBackward: { seconds in
|
|
playerService.seekBackward(by: seconds)
|
|
},
|
|
onToggleFullscreen: { [self] in
|
|
toggleFullscreen()
|
|
},
|
|
isFullscreen: !isPortraitPanelVisible,
|
|
isWidescreenVideo: playerState.displayAspectRatio > 1.0,
|
|
onClose: { [self] in
|
|
closeVideo()
|
|
},
|
|
onTogglePiP: {
|
|
if let mpvBackend = playerService.currentBackend as? MPVBackend {
|
|
mpvBackend.togglePiP()
|
|
}
|
|
},
|
|
onToggleDebug: {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
playerState.showDebugOverlay.toggle()
|
|
}
|
|
},
|
|
isWideScreenLayout: isFullscreen, // In fullscreen, use widescreen-style safe area handling
|
|
onTogglePanel: nil,
|
|
isPanelVisible: true,
|
|
panelSide: .right,
|
|
onToggleOrientationLock: { [weak appEnvironment] in
|
|
appEnvironment?.settingsManager.inAppOrientationLock.toggle()
|
|
},
|
|
isOrientationLocked: inAppOrientationLock,
|
|
onToggleDetailsVisibility: { [self] in
|
|
scrollPosition.scrollTo(y: 0)
|
|
if !isPortraitPanelVisible {
|
|
navigationCoordinator?.pinchPanscan = 0
|
|
}
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
isPortraitPanelVisible.toggle()
|
|
}
|
|
navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible
|
|
},
|
|
onShowSettings: { [self] in
|
|
showingQualitySheet = true
|
|
},
|
|
onPlayNext: {
|
|
await playerService.playNext()
|
|
},
|
|
onPlayPrevious: {
|
|
await playerService.playPrevious()
|
|
},
|
|
onShowQueue: { [self] in
|
|
showingQueueSheet = true
|
|
},
|
|
onVolumeChanged: { [weak appEnvironment] volume in
|
|
playerService.currentBackend?.volume = volume
|
|
appEnvironment?.settingsManager.playerVolume = volume
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
onMuteToggled: { [weak appEnvironment] in
|
|
let newMuted = !playerState.isMuted
|
|
playerService.currentBackend?.isMuted = newMuted
|
|
playerState.isMuted = newMuted
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
currentVideo: playerState.currentVideo,
|
|
availableCaptions: playerService.availableCaptions,
|
|
currentCaption: playerService.currentCaption,
|
|
availableStreams: playerService.availableStreams,
|
|
currentStream: playerState.currentStream,
|
|
currentAudioStream: playerState.currentAudioStream,
|
|
onRateChanged: { rate in
|
|
playerState.rate = rate
|
|
playerService.currentBackend?.rate = Float(rate.rawValue)
|
|
},
|
|
onCaptionSelected: { caption in
|
|
playerService.loadCaption(caption)
|
|
},
|
|
onStreamSelected: { [self] stream, audioStream in
|
|
switchToStream(stream, audioStream: audioStream)
|
|
},
|
|
panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0,
|
|
isPanscanAllowed: !isPortraitPanelVisible,
|
|
onTogglePanscan: { [weak navigationCoordinator] in
|
|
navigationCoordinator?.togglePanscan()
|
|
},
|
|
activeLayout: activeLayout
|
|
)
|
|
.frame(width: controlsWidth, height: controlsHeight)
|
|
.offset(y: controlsVerticalOffset)
|
|
}
|
|
#elseif os(macOS)
|
|
// No backend gate: the backend is created only after the video
|
|
// details fetch, and controls should be visible while loading.
|
|
if let playerState,
|
|
let playerService,
|
|
playerState.pipState != .active && !playerState.showDebugOverlay {
|
|
MacOSPlayerControlsView(
|
|
playerState: playerState,
|
|
onPlayPause: {
|
|
playerService.togglePlayPause()
|
|
},
|
|
onSeek: { time in
|
|
await playerService.seek(to: time)
|
|
},
|
|
onSeekForward: { seconds in
|
|
playerService.seekForward(by: seconds)
|
|
},
|
|
onSeekBackward: { seconds in
|
|
playerService.seekBackward(by: seconds)
|
|
},
|
|
isFullscreen: !isPortraitPanelVisible,
|
|
onClose: { [self] in
|
|
closeVideo()
|
|
},
|
|
onTogglePiP: {
|
|
if let mpvBackend = playerService.currentBackend as? MPVBackend {
|
|
mpvBackend.togglePiP()
|
|
}
|
|
},
|
|
onPlayNext: {
|
|
await playerService.playNext()
|
|
},
|
|
onPlayPrevious: {
|
|
await playerService.playPrevious()
|
|
},
|
|
onVolumeChanged: { [weak appEnvironment] volume in
|
|
playerService.currentBackend?.volume = volume
|
|
appEnvironment?.settingsManager.playerVolume = volume
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
onMuteToggled: { [weak appEnvironment] in
|
|
let newMuted = !playerState.isMuted
|
|
playerService.currentBackend?.isMuted = newMuted
|
|
playerState.isMuted = newMuted
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
onShowSettings: { [self] in
|
|
showingQualitySheet = true
|
|
},
|
|
onShowQueue: { [self] in
|
|
showingQueueSheet = true
|
|
},
|
|
onShowPlaylistSelector: { [self] in
|
|
showingPlaylistSheet = true
|
|
},
|
|
onRateChanged: { rate in
|
|
playerState.rate = rate
|
|
playerService.currentBackend?.rate = Float(rate.rawValue)
|
|
}
|
|
)
|
|
.frame(width: controlsWidth, height: controlsHeight)
|
|
.offset(y: controlsVerticalOffset)
|
|
}
|
|
#endif
|
|
|
|
// Debug overlay
|
|
#if os(iOS)
|
|
if let playerState {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
playerState.showDebugOverlay = false
|
|
}
|
|
.opacity(playerState.showDebugOverlay ? 1 : 0)
|
|
.allowsHitTesting(playerState.showDebugOverlay)
|
|
|
|
VStack {
|
|
HStack {
|
|
MPVDebugOverlay(
|
|
stats: debugStats,
|
|
isVisible: Binding(
|
|
get: { playerState.showDebugOverlay },
|
|
set: { playerState.showDebugOverlay = $0 }
|
|
),
|
|
isLandscape: false
|
|
)
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(12)
|
|
.allowsHitTesting(false)
|
|
.opacity(playerState.showDebugOverlay ? 1 : 0)
|
|
.animation(.easeInOut(duration: 0.2), value: playerState.showDebugOverlay)
|
|
}
|
|
#endif
|
|
|
|
// Debug overlay (macOS)
|
|
#if os(macOS)
|
|
if let playerState, playerState.showDebugOverlay {
|
|
// Tap anywhere to dismiss
|
|
Color.black.opacity(0.3)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
playerState.showDebugOverlay = false
|
|
}
|
|
|
|
VStack {
|
|
HStack {
|
|
MPVDebugOverlay(
|
|
stats: debugStats,
|
|
isVisible: Binding(
|
|
get: { playerState.showDebugOverlay },
|
|
set: { playerState.showDebugOverlay = $0 }
|
|
),
|
|
isLandscape: true // macOS uses landscape layout
|
|
)
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.leading, 80) // Avoid window traffic light buttons
|
|
.padding(.top, 16)
|
|
.padding(.trailing, 16)
|
|
.padding(.bottom, 16)
|
|
.allowsHitTesting(false)
|
|
.transition(.opacity)
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - Portrait Controls Overlay
|
|
|
|
/// Renders just the player controls for portrait mode (used as overlay outside clipped area).
|
|
@ViewBuilder
|
|
func portraitControlsOverlay(
|
|
screenWidth: CGFloat,
|
|
screenHeight: CGFloat,
|
|
fitHeight: CGFloat,
|
|
isFullscreen: Bool,
|
|
forceHidden: Bool = false,
|
|
onTogglePanel: (() -> Void)? = nil,
|
|
isPanelVisible: Bool = true,
|
|
isPanelPinned: Bool = true,
|
|
videoAreaTop: CGFloat = 0,
|
|
videoAreaHeight: CGFloat? = nil,
|
|
videoFitHeight: CGFloat? = nil,
|
|
activeLayout: PlayerControlsLayout = .default
|
|
) -> some View {
|
|
// Controls always use full screen dimensions to prevent layout shifts
|
|
let controlsWidth = max(1, screenWidth)
|
|
let controlsHeight = max(1, screenHeight)
|
|
|
|
#if os(iOS)
|
|
if let backend = playerService?.currentBackend,
|
|
backend.backendType == .mpv,
|
|
let playerState,
|
|
let playerService,
|
|
playerState.pipState != .active && !playerState.showDebugOverlay {
|
|
PlayerControlsView(
|
|
playerState: playerState,
|
|
onPlayPause: {
|
|
playerService.togglePlayPause()
|
|
},
|
|
onSeek: { time in
|
|
await playerService.seek(to: time)
|
|
},
|
|
onSeekForward: { seconds in
|
|
playerService.seekForward(by: seconds)
|
|
},
|
|
onSeekBackward: { seconds in
|
|
playerService.seekBackward(by: seconds)
|
|
},
|
|
onToggleFullscreen: { [self] in
|
|
toggleFullscreen()
|
|
},
|
|
isFullscreen: !isPortraitPanelVisible,
|
|
isWidescreenVideo: playerState.displayAspectRatio > 1.0,
|
|
onClose: { [self] in
|
|
closeVideo()
|
|
},
|
|
onTogglePiP: {
|
|
if let mpvBackend = backend as? MPVBackend {
|
|
mpvBackend.togglePiP()
|
|
}
|
|
},
|
|
onToggleDebug: {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
playerState.showDebugOverlay.toggle()
|
|
}
|
|
},
|
|
isWideScreenLayout: !isPortraitPanelVisible,
|
|
onTogglePanel: onTogglePanel,
|
|
isPanelVisible: isPanelVisible,
|
|
panelSide: .right,
|
|
isPanelPinned: isPanelPinned,
|
|
onToggleOrientationLock: { [weak appEnvironment] in
|
|
appEnvironment?.settingsManager.inAppOrientationLock.toggle()
|
|
},
|
|
isOrientationLocked: inAppOrientationLock,
|
|
onToggleDetailsVisibility: { [self] in
|
|
// Hide controls during transition to avoid animation glitch
|
|
hideControlsDuringTransition = true
|
|
if !isPortraitPanelVisible {
|
|
navigationCoordinator?.pinchPanscan = 0
|
|
}
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
isPortraitPanelVisible.toggle()
|
|
}
|
|
navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible
|
|
// Re-enable controls after animation completes (0.35s)
|
|
// Controls will still be hidden (forceInitialHidden) until user taps
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
|
hideControlsDuringTransition = false
|
|
}
|
|
},
|
|
onShowSettings: { [self] in
|
|
showingQualitySheet = true
|
|
},
|
|
onPlayNext: {
|
|
await playerService.playNext()
|
|
},
|
|
onPlayPrevious: {
|
|
await playerService.playPrevious()
|
|
},
|
|
onShowQueue: { [self] in
|
|
showingQueueSheet = true
|
|
},
|
|
forceInitialHidden: forceHidden,
|
|
videoAreaTop: videoAreaTop,
|
|
videoAreaHeight: videoAreaHeight,
|
|
videoFitHeight: videoFitHeight,
|
|
onVolumeChanged: { [weak appEnvironment] volume in
|
|
playerService.currentBackend?.volume = volume
|
|
appEnvironment?.settingsManager.playerVolume = volume
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
onMuteToggled: { [weak appEnvironment] in
|
|
let newMuted = !playerState.isMuted
|
|
playerService.currentBackend?.isMuted = newMuted
|
|
playerState.isMuted = newMuted
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
currentVideo: playerState.currentVideo,
|
|
availableCaptions: playerService.availableCaptions,
|
|
currentCaption: playerService.currentCaption,
|
|
availableStreams: playerService.availableStreams,
|
|
currentStream: playerState.currentStream,
|
|
currentAudioStream: playerState.currentAudioStream,
|
|
onRateChanged: { rate in
|
|
playerState.rate = rate
|
|
playerService.currentBackend?.rate = Float(rate.rawValue)
|
|
},
|
|
onCaptionSelected: { caption in
|
|
playerService.loadCaption(caption)
|
|
},
|
|
onStreamSelected: { [self] stream, audioStream in
|
|
switchToStream(stream, audioStream: audioStream)
|
|
},
|
|
panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0,
|
|
isPanscanAllowed: !isPortraitPanelVisible,
|
|
onTogglePanscan: { [weak navigationCoordinator] in
|
|
navigationCoordinator?.togglePanscan()
|
|
},
|
|
activeLayout: activeLayout
|
|
)
|
|
.frame(width: controlsWidth, height: controlsHeight)
|
|
}
|
|
#elseif os(macOS)
|
|
// No backend gate: the backend is created only after the video
|
|
// details fetch, and controls should be visible while loading.
|
|
if let playerState,
|
|
let playerService,
|
|
playerState.pipState != .active && !playerState.showDebugOverlay {
|
|
MacOSPlayerControlsView(
|
|
playerState: playerState,
|
|
onPlayPause: {
|
|
playerService.togglePlayPause()
|
|
},
|
|
onSeek: { time in
|
|
await playerService.seek(to: time)
|
|
},
|
|
onSeekForward: { seconds in
|
|
playerService.seekForward(by: seconds)
|
|
},
|
|
onSeekBackward: { seconds in
|
|
playerService.seekBackward(by: seconds)
|
|
},
|
|
isFullscreen: !isPortraitPanelVisible,
|
|
onClose: { [self] in
|
|
closeVideo()
|
|
},
|
|
onTogglePiP: {
|
|
if let mpvBackend = playerService.currentBackend as? MPVBackend {
|
|
mpvBackend.togglePiP()
|
|
}
|
|
},
|
|
onPlayNext: {
|
|
await playerService.playNext()
|
|
},
|
|
onPlayPrevious: {
|
|
await playerService.playPrevious()
|
|
},
|
|
onVolumeChanged: { [weak appEnvironment] volume in
|
|
playerService.currentBackend?.volume = volume
|
|
appEnvironment?.settingsManager.playerVolume = volume
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
onMuteToggled: { [weak appEnvironment] in
|
|
let newMuted = !playerState.isMuted
|
|
playerService.currentBackend?.isMuted = newMuted
|
|
playerState.isMuted = newMuted
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
onShowSettings: { [self] in
|
|
showingQualitySheet = true
|
|
},
|
|
onShowQueue: { [self] in
|
|
showingQueueSheet = true
|
|
},
|
|
onShowPlaylistSelector: { [self] in
|
|
showingPlaylistSheet = true
|
|
},
|
|
onRateChanged: { rate in
|
|
playerState.rate = rate
|
|
playerService.currentBackend?.rate = Float(rate.rawValue)
|
|
}
|
|
)
|
|
.frame(width: controlsWidth, height: controlsHeight)
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - Widescreen Layout (iOS/macOS only)
|
|
|
|
#if os(iOS) || os(macOS)
|
|
|
|
extension ExpandedPlayerSheet {
|
|
// MARK: - Widescreen Content
|
|
|
|
/// Widescreen layout with floating panel.
|
|
@ViewBuilder
|
|
func wideScreenContent(video: Video) -> some View {
|
|
WideScreenPlayerLayout(
|
|
playerControlsLayout: playerControlsLayout,
|
|
playerContent: { onTogglePanel, isPanelVisible, isPanelPinned, panelSide, onHidePanel, leadingSafeArea, trailingSafeArea, fullWidth, fullHeight in
|
|
playerAreaForWidescreen(
|
|
onTogglePanel: onTogglePanel,
|
|
isPanelVisible: isPanelVisible,
|
|
isPanelPinned: isPanelPinned,
|
|
panelSide: panelSide,
|
|
onHidePanel: onHidePanel,
|
|
leadingSafeArea: leadingSafeArea,
|
|
trailingSafeArea: trailingSafeArea,
|
|
fullWidth: fullWidth,
|
|
fullHeight: fullHeight
|
|
)
|
|
},
|
|
onChannelTap: video.author.hasRealChannelInfo ? {
|
|
navigationCoordinator?.navigateToChannel(for: video, collapsePlayer: true)
|
|
} : nil,
|
|
onFullscreen: {
|
|
#if os(iOS)
|
|
self.toggleFullscreen()
|
|
#endif
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - Widescreen Player Area
|
|
|
|
/// Player area for widescreen layout with panel support.
|
|
@ViewBuilder
|
|
func playerAreaForWidescreen(
|
|
onTogglePanel: @escaping () -> Void,
|
|
isPanelVisible: Bool,
|
|
isPanelPinned: Bool,
|
|
panelSide: FloatingPanelSide,
|
|
onHidePanel: @escaping () -> Void,
|
|
leadingSafeArea: CGFloat = 0,
|
|
trailingSafeArea: CGFloat = 0,
|
|
fullWidth: CGFloat = 0,
|
|
fullHeight: CGFloat = 0
|
|
) -> some View {
|
|
let info = playbackInfo
|
|
|
|
// Hide controls when floating panel is visible (not pinned)
|
|
// Controls only shown when panel is pinned or panel is not visible
|
|
let shouldShowControls = isPanelPinned || !isPanelVisible
|
|
|
|
// Use consistent aspect ratio - actual video ratio when known, otherwise 16:9
|
|
// Ensure valid aspect ratio (avoid division by zero)
|
|
let rawAspectRatio = playerState?.displayAspectRatio ?? (16.0 / 9.0)
|
|
let aspectRatio = rawAspectRatio > 0 ? rawAspectRatio : (16.0 / 9.0)
|
|
|
|
// Use geometry passed from WideScreenPlayerLayout (no nested GeometryReader needed)
|
|
// Calculate usable area after accounting for safe areas
|
|
let availableWidth = max(1, fullWidth - leadingSafeArea - trailingSafeArea)
|
|
let availableHeight = max(1, fullHeight)
|
|
|
|
// Calculate offset for controls positioning (used for animation)
|
|
let controlsOffset = (leadingSafeArea - trailingSafeArea) / 2
|
|
|
|
// Calculate video frame size based on panscan
|
|
// At panscan 0: aspect-fit (may have black bars)
|
|
// At panscan 1: aspect-fill (cropped to fill)
|
|
let fitSize: CGSize = {
|
|
let widthBasedHeight = availableWidth / aspectRatio
|
|
let heightBasedWidth = availableHeight * aspectRatio
|
|
if widthBasedHeight <= availableHeight {
|
|
return CGSize(width: max(1, availableWidth), height: max(1, widthBasedHeight))
|
|
} else {
|
|
return CGSize(width: max(1, heightBasedWidth), height: max(1, availableHeight))
|
|
}
|
|
}()
|
|
|
|
let fillSize: CGSize = {
|
|
let widthBasedHeight = availableWidth / aspectRatio
|
|
let heightBasedWidth = availableHeight * aspectRatio
|
|
if widthBasedHeight >= availableHeight {
|
|
return CGSize(width: max(1, availableWidth), height: max(1, widthBasedHeight))
|
|
} else {
|
|
return CGSize(width: max(1, heightBasedWidth), height: max(1, availableHeight))
|
|
}
|
|
}()
|
|
|
|
#if os(iOS)
|
|
// Use UIKit pinch gesture panscan from NavigationCoordinator
|
|
let panscan = navigationCoordinator?.pinchPanscan ?? 0.0
|
|
#else
|
|
let panscan = 0.0
|
|
#endif
|
|
|
|
// Interpolate between fit and fill based on panscan
|
|
let videoWidth = max(1, fitSize.width + (fillSize.width - fitSize.width) * panscan)
|
|
let videoHeight = max(1, fitSize.height + (fillSize.height - fitSize.height) * panscan)
|
|
|
|
ZStack {
|
|
// Black background fills entire space (extends under safe areas)
|
|
Color.black
|
|
|
|
// Content container - constrained by safe area padding
|
|
// Both video and controls share the same constrained space
|
|
ZStack {
|
|
// Invisible layer to force ZStack to fill frame
|
|
Color.black.opacity(0.001)
|
|
.frame(width: availableWidth, height: availableHeight)
|
|
|
|
// Video layer - sized based on panscan, centered within available area
|
|
ZStack {
|
|
// Thumbnail layer - fades in on appear, fades out when player is ready and buffer is loaded
|
|
// During transition, use frozen URL to prevent old thumbnail flash
|
|
if let video = playerState?.currentVideo {
|
|
let isFirstFrameReady = playerState?.isFirstFrameReady ?? false
|
|
let isBufferReady = playerState?.isBufferReady ?? false
|
|
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
|
|
let showThumbnail = !info.hasBackend || !isFirstFrameReady || !isBufferReady || isAudioOnly
|
|
// Use frozen URL during transition, otherwise current video's thumbnail
|
|
let thumbnailURL = isThumbnailFrozen ? displayedThumbnailURL : video.bestThumbnail?.url
|
|
|
|
// Hidden loader - loads image into @State (invisible)
|
|
LazyImage(url: thumbnailURL) { state in
|
|
Color.clear
|
|
.onChange(of: state.image) { _, newImage in
|
|
if let newImage { displayedThumbnailImage = newImage }
|
|
}
|
|
.onAppear {
|
|
if let image = state.image { displayedThumbnailImage = image }
|
|
}
|
|
}
|
|
.frame(width: 1, height: 1)
|
|
.opacity(0)
|
|
|
|
// Stable display - @State image never flashes during re-renders
|
|
Group {
|
|
if let thumbnailImage = displayedThumbnailImage {
|
|
thumbnailImage.resizable().aspectRatio(contentMode: .fill)
|
|
} else {
|
|
Color.black
|
|
}
|
|
}
|
|
.frame(width: videoWidth, height: videoHeight)
|
|
.opacity(showThumbnail ? 1 : 0)
|
|
.animation(.easeInOut(duration: 0.2), value: showThumbnail)
|
|
.transition(.opacity.animation(.easeInOut(duration: 0.3)))
|
|
.animation(.easeInOut(duration: 0.3), value: video.id)
|
|
}
|
|
|
|
// Video player layer - fades in when ready
|
|
if let backend = playerService?.currentBackend as? MPVBackend,
|
|
let playerState,
|
|
let playerService,
|
|
info.hasBackend {
|
|
let isAudioOnlyStream = playerState.currentStream?.isAudioOnly == true
|
|
MPVVideoView(
|
|
backend: backend,
|
|
playerState: playerState,
|
|
playerService: playerService,
|
|
showsControls: false, // Controls rendered separately at full size
|
|
isWideScreenLayout: true,
|
|
onTogglePanel: onTogglePanel,
|
|
isPanelVisible: isPanelVisible,
|
|
panelSide: panelSide
|
|
)
|
|
.frame(width: videoWidth, height: videoHeight)
|
|
// Only show video when both first frame is ready AND buffer is ready
|
|
// This prevents showing a frozen frame before playback can start smoothly
|
|
// Hide for audio-only streams - show thumbnail instead
|
|
.opacity(info.hasBackend && playerState.isFirstFrameReady && playerState.isBufferReady && !isAudioOnlyStream ? 1 : 0)
|
|
.animation(.easeInOut(duration: 0.2), value: info.hasBackend && playerState.isFirstFrameReady && playerState.isBufferReady && !isAudioOnlyStream)
|
|
}
|
|
|
|
// Fallback when no video is set
|
|
if playerState?.currentVideo == nil {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.controlSize(.large)
|
|
}
|
|
}
|
|
.frame(width: videoWidth, height: videoHeight)
|
|
.matchedGeometryEffect(id: "player", in: playerNamespace)
|
|
.geometryGroup()
|
|
.clipped()
|
|
|
|
// Loading overlay - covers full available area (including letterbox bars)
|
|
if playerState?.currentVideo != nil {
|
|
let isWaitingForBuffer = !(playerState?.isBufferReady ?? true)
|
|
thumbnailOverlayContent(
|
|
isIdle: info.isIdle,
|
|
isEnded: info.isEnded,
|
|
isFailed: info.isFailed,
|
|
isLoading: info.isLoading || isWaitingForBuffer
|
|
)
|
|
.frame(width: availableWidth, height: availableHeight)
|
|
}
|
|
}
|
|
.frame(width: availableWidth, height: availableHeight)
|
|
.position(x: leadingSafeArea + availableWidth / 2, y: availableHeight / 2)
|
|
.clipped()
|
|
|
|
// Controls layer - positioned using offset for smooth animation
|
|
// Hidden when floating panel is visible (unless paused)
|
|
#if os(iOS)
|
|
if let backend = playerService?.currentBackend,
|
|
backend.backendType == .mpv,
|
|
let playerState,
|
|
let playerService,
|
|
shouldShowControls,
|
|
playerState.pipState != .active && !playerState.showDebugOverlay {
|
|
// Controls using offset for smooth animation when panel side changes
|
|
PlayerControlsView(
|
|
playerState: playerState,
|
|
onPlayPause: {
|
|
playerService.togglePlayPause()
|
|
},
|
|
onSeek: { time in
|
|
await playerService.seek(to: time)
|
|
},
|
|
onSeekForward: { seconds in
|
|
playerService.seekForward(by: seconds)
|
|
},
|
|
onSeekBackward: { seconds in
|
|
playerService.seekBackward(by: seconds)
|
|
},
|
|
onToggleFullscreen: { [self] in
|
|
toggleFullscreen()
|
|
},
|
|
isFullscreen: true, // In widescreen layout = fullscreen
|
|
isWidescreenVideo: playerState.displayAspectRatio > 1.0,
|
|
onClose: { [self] in
|
|
closeVideo()
|
|
},
|
|
onTogglePiP: {
|
|
if let mpvBackend = playerService.currentBackend as? MPVBackend {
|
|
mpvBackend.togglePiP()
|
|
}
|
|
},
|
|
onToggleDebug: {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
playerState.showDebugOverlay.toggle()
|
|
}
|
|
},
|
|
isWideScreenLayout: !(isPanelPinned && isPanelVisible),
|
|
onTogglePanel: onTogglePanel,
|
|
isPanelVisible: isPanelVisible,
|
|
panelSide: panelSide,
|
|
isPanelPinned: isPanelPinned,
|
|
layoutLeadingSafeArea: 0.01, // Non-zero indicates safe areas handled externally
|
|
layoutTrailingSafeArea: 0.01,
|
|
onToggleOrientationLock: { [weak appEnvironment] in
|
|
appEnvironment?.settingsManager.inAppOrientationLock.toggle()
|
|
},
|
|
isOrientationLocked: inAppOrientationLock,
|
|
onShowSettings: { [self] in
|
|
showingQualitySheet = true
|
|
},
|
|
onPlayNext: {
|
|
await playerService.playNext()
|
|
},
|
|
onPlayPrevious: {
|
|
await playerService.playPrevious()
|
|
},
|
|
onShowQueue: { [self] in
|
|
showingQueueSheet = true
|
|
},
|
|
videoAreaTop: (availableHeight - videoHeight) / 2,
|
|
videoAreaHeight: videoHeight,
|
|
videoFitHeight: fitSize.height,
|
|
onVolumeChanged: { [weak appEnvironment] volume in
|
|
playerService.currentBackend?.volume = volume
|
|
appEnvironment?.settingsManager.playerVolume = volume
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
onMuteToggled: { [weak appEnvironment] in
|
|
let newMuted = !playerState.isMuted
|
|
playerService.currentBackend?.isMuted = newMuted
|
|
playerState.isMuted = newMuted
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
currentVideo: playerState.currentVideo,
|
|
availableCaptions: playerService.availableCaptions,
|
|
currentCaption: playerService.currentCaption,
|
|
availableStreams: playerService.availableStreams,
|
|
currentStream: playerState.currentStream,
|
|
currentAudioStream: playerState.currentAudioStream,
|
|
onRateChanged: { rate in
|
|
playerState.rate = rate
|
|
playerService.currentBackend?.rate = Float(rate.rawValue)
|
|
},
|
|
onCaptionSelected: { caption in
|
|
playerService.loadCaption(caption)
|
|
},
|
|
onStreamSelected: { [self] stream, audioStream in
|
|
switchToStream(stream, audioStream: audioStream)
|
|
},
|
|
panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0,
|
|
isPanscanAllowed: !(isPanelPinned && isPanelVisible),
|
|
onTogglePanscan: { [weak navigationCoordinator] in
|
|
navigationCoordinator?.togglePanscan()
|
|
},
|
|
activeLayout: playerControlsLayout
|
|
)
|
|
.frame(width: availableWidth, height: availableHeight)
|
|
.offset(x: controlsOffset)
|
|
// DEBUG: Show layout values when setting enabled
|
|
.overlay(alignment: panelSide == .right ? .topLeading : .topTrailing) {
|
|
if appEnvironment?.settingsManager.showPlayerAreaDebug == true {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(verbatim: "Layout (yellow):")
|
|
.fontWeight(.bold)
|
|
Text(verbatim: "leadSA: \(Int(leadingSafeArea)) trailSA: \(Int(trailingSafeArea))")
|
|
Text(verbatim: "availW: \(Int(availableWidth)) fullW: \(Int(fullWidth))")
|
|
Text(verbatim: "fullH: \(Int(fullHeight)) offset: \(Int(controlsOffset))")
|
|
Text(verbatim: "pinned: \(isPanelPinned ? "Y" : "N") vis: \(isPanelVisible ? "Y" : "N") side: \(panelSide == .left ? "L" : "R")")
|
|
#if os(iOS)
|
|
let orientation = UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.first?.interfaceOrientation
|
|
Text(verbatim: "orient: \(orientation == .landscapeLeft ? "LL" : orientation == .landscapeRight ? "LR" : "P")")
|
|
#endif
|
|
}
|
|
.font(.system(size: 9, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.yellow)
|
|
.padding(4)
|
|
.background(.black.opacity(0.8))
|
|
.padding(panelSide == .right ? .leading : .trailing, 8)
|
|
.padding(.top, 50)
|
|
}
|
|
}
|
|
.overlay {
|
|
if appEnvironment?.settingsManager.showPlayerAreaDebug == true {
|
|
Rectangle()
|
|
.stroke(Color.red, lineWidth: 2)
|
|
}
|
|
}
|
|
}
|
|
#elseif os(macOS)
|
|
// No backend gate: the backend is created only after the video
|
|
// details fetch, and controls should be visible while loading.
|
|
if let playerState,
|
|
let playerService,
|
|
playerState.pipState != .active && !playerState.showDebugOverlay {
|
|
MacOSPlayerControlsView(
|
|
playerState: playerState,
|
|
onPlayPause: {
|
|
playerService.togglePlayPause()
|
|
},
|
|
onSeek: { time in
|
|
await playerService.seek(to: time)
|
|
},
|
|
onSeekForward: { seconds in
|
|
playerService.seekForward(by: seconds)
|
|
},
|
|
onSeekBackward: { seconds in
|
|
playerService.seekBackward(by: seconds)
|
|
},
|
|
onToggleFullscreen: {
|
|
// Toggle native macOS fullscreen (drops the floating
|
|
// config first so a pinned window can enter fullscreen)
|
|
ExpandedPlayerWindowManager.shared.toggleFullScreen()
|
|
},
|
|
// The coordinator flag covers the inline overlay (where the
|
|
// main window is the fullscreen one) and, being @Observable,
|
|
// refreshes the button icon on toggle; the keyWindow check
|
|
// covers the separate-window presentation.
|
|
isFullscreen: navigationCoordinator?.isMacInlinePlayerFullScreen == true
|
|
|| NSApp.keyWindow?.styleMask.contains(.fullScreen) == true,
|
|
// Esc collapses the inline overlay to the mini bar; in
|
|
// separate-window mode Esc keeps its default handling.
|
|
onCollapse: appEnvironment?.settingsManager.macPlayerSeparateWindow == true
|
|
? nil
|
|
: { [weak navigationCoordinator] in
|
|
navigationCoordinator?.isPlayerExpanded = false
|
|
},
|
|
onClose: { [self] in
|
|
closeVideo()
|
|
},
|
|
onTogglePiP: {
|
|
if let mpvBackend = playerService.currentBackend as? MPVBackend {
|
|
mpvBackend.togglePiP()
|
|
}
|
|
},
|
|
onPlayNext: {
|
|
await playerService.playNext()
|
|
},
|
|
onPlayPrevious: {
|
|
await playerService.playPrevious()
|
|
},
|
|
onVolumeChanged: { [weak appEnvironment] volume in
|
|
playerService.currentBackend?.volume = volume
|
|
appEnvironment?.settingsManager.playerVolume = volume
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
onMuteToggled: { [weak appEnvironment] in
|
|
let newMuted = !playerState.isMuted
|
|
playerService.currentBackend?.isMuted = newMuted
|
|
playerState.isMuted = newMuted
|
|
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
|
|
},
|
|
onShowSettings: { [self] in
|
|
showingQualitySheet = true
|
|
},
|
|
onShowQueue: { [self] in
|
|
showingQueueSheet = true
|
|
},
|
|
onShowPlaylistSelector: { [self] in
|
|
showingPlaylistSheet = true
|
|
},
|
|
onTitleTap: {
|
|
onTogglePanel()
|
|
},
|
|
isDetailsPanelVisible: isPanelVisible,
|
|
onRateChanged: { rate in
|
|
playerState.rate = rate
|
|
playerService.currentBackend?.rate = Float(rate.rawValue)
|
|
}
|
|
)
|
|
.frame(width: availableWidth, height: availableHeight)
|
|
.offset(x: controlsOffset)
|
|
}
|
|
#endif
|
|
|
|
#if os(iOS)
|
|
// Debug overlay - tap to dismiss layer (widescreen)
|
|
if let playerState, playerState.showDebugOverlay {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
playerState.showDebugOverlay = false
|
|
}
|
|
}
|
|
|
|
// Debug overlay content (widescreen)
|
|
if let playerState, playerState.showDebugOverlay {
|
|
VStack {
|
|
HStack {
|
|
MPVDebugOverlay(
|
|
stats: debugStats,
|
|
isVisible: Binding(
|
|
get: { playerState.showDebugOverlay },
|
|
set: { playerState.showDebugOverlay = $0 }
|
|
),
|
|
isLandscape: true
|
|
)
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.leading, windowSafeAreaInsets.left + 16)
|
|
.padding(.top, windowSafeAreaInsets.top + 16)
|
|
.padding(.trailing, 16)
|
|
.padding(.bottom, 16)
|
|
.allowsHitTesting(false)
|
|
.transition(.opacity)
|
|
}
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
// Debug overlay - tap to dismiss layer (widescreen macOS)
|
|
if let playerState, playerState.showDebugOverlay {
|
|
Color.black.opacity(0.3)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
playerState.showDebugOverlay = false
|
|
}
|
|
|
|
// Debug overlay content (widescreen macOS)
|
|
VStack {
|
|
HStack {
|
|
MPVDebugOverlay(
|
|
stats: debugStats,
|
|
isVisible: Binding(
|
|
get: { playerState.showDebugOverlay },
|
|
set: { playerState.showDebugOverlay = $0 }
|
|
),
|
|
isLandscape: true
|
|
)
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.leading, 80) // Avoid window traffic light buttons
|
|
.padding(.top, 16)
|
|
.padding(.trailing, 16)
|
|
.padding(.bottom, 16)
|
|
.allowsHitTesting(false)
|
|
.transition(.opacity)
|
|
}
|
|
#endif
|
|
|
|
// Tap to hide panel overlay - only active when panel visible and not pinned
|
|
if isPanelVisible && !isPanelPinned {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
onHidePanel()
|
|
}
|
|
}
|
|
}
|
|
.clipped() // Clip to screen bounds
|
|
.animation(.easeInOut(duration: 0.3), value: panelSide)
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
#endif
|