Files
yattee/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift
Arkadiusz Fal 6c30e745d9 Fix player dismiss gesture stuck after panel dismiss with comments expanded
Reset isCommentsExpanded and commentsFrame on the NavigationCoordinator
directly when the portrait panel is dismissed, since PortraitDetailsPanel
owns its own @State that doesn't sync back through .onChange during dismiss.
Also track comments overlay frame via GeometryReader so the dismiss gesture
can allow swipes outside the comments area instead of blanket-blocking.
2026-02-12 04:42:32 +01:00

1661 lines
81 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)
// 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() },
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 = backend 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)
if let backend = playerService?.currentBackend,
backend.backendType == .mpv,
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 = backend as? MPVBackend {
mpvBackend.togglePiP()
}
},
onPlayNext: {
await playerService.playNext()
},
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
}
)
.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)
if let backend = playerService?.currentBackend,
backend.backendType == .mpv,
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 = backend as? MPVBackend {
mpvBackend.togglePiP()
}
},
onPlayNext: {
await playerService.playNext()
},
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
}
)
.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 = backend 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)
if let backend = playerService?.currentBackend,
backend.backendType == .mpv,
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
NSApp.keyWindow?.toggleFullScreen(nil)
},
isFullscreen: NSApp.keyWindow?.styleMask.contains(.fullScreen) == true,
onClose: { [self] in
closeVideo()
},
onTogglePiP: {
if let mpvBackend = backend as? MPVBackend {
mpvBackend.togglePiP()
}
},
onPlayNext: {
await playerService.playNext()
},
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
}
)
.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