// // 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 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("Layout (yellow):") .fontWeight(.bold) Text("leadSA: \(Int(leadingSafeArea)) trailSA: \(Int(trailingSafeArea))") Text("availW: \(Int(availableWidth)) fullW: \(Int(fullWidth))") Text("fullH: \(Int(fullHeight)) offset: \(Int(controlsOffset))") Text("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("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