// // ExpandedPlayerSheet.swift // Yattee // // Full-screen player sheet with video, info, and controls. // import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) // MARK: - Expanded Player Sheet struct ExpandedPlayerSheet: View { @Environment(\.appEnvironment) var appEnvironment @Environment(\.dismiss) private var dismiss // MARK: - Sheet State @State var showingQualitySheet = false @State private var showingPlaylistSheet = false @State var showingDownloadSheet = false @State var showingDeleteDownloadAlert = false @State var showingQueueSheet = false @State var showingErrorSheet = false @State var isCommentsExpanded: Bool = false @State var commentsDismissOffset: CGFloat = 0 /// Direct scroll position control - use scrollTo(y:) to scroll to top @State var scrollPosition: ScrollPosition = .init(y: 0) @State private var showScrollButton = false @State private var currentScrollOffset: CGFloat = 0 @State var currentPlayerHeight: CGFloat = 0 @State private var isBottomOverscroll: Bool = false @State var isPortraitPanelVisible: Bool = true /// Temporarily hide controls during fullscreen transition to avoid animation glitch @State var hideControlsDuringTransition: Bool = false // MARK: - Panel Drag State /// Current drag offset when dismissing panel (positive = dragging down) @State var panelDragOffset: CGFloat = 0 /// Whether panel is currently being dragged @State var isPanelDragging: Bool = false /// Current reveal offset when revealing hidden panel (negative = dragging up) @State var panelRevealOffset: CGFloat = 0 /// Video Y offset for animating position (separate from computed position for smooth animation) @State var videoYOffset: CGFloat = 0 /// Whether panel is expanded to cover the player (full-screen panel) @State var isPanelExpanded: Bool = false /// Current drag offset when expanding panel (negative = dragging up) @State var panelExpandOffset: CGFloat = 0 /// Whether to use compact panel (no description) - animated state @State var useCompactPanel: Bool = false @State var showFormattedDate = false @State var showOriginalTitle = false /// Track if we're in widescreen layout for status bar hiding @State private var isInWideScreenLayout = false /// Active player controls layout from preset (loaded once, updated on notification) @State var playerControlsLayout: PlayerControlsLayout = .default // MARK: - Autoplay Countdown State /// Current countdown value (seconds remaining) @State var autoplayCountdown: Int = 0 /// Timer for countdown @State var autoplayTimer: Timer? /// Whether user cancelled the autoplay @State var isAutoplayCancelled: Bool = false /// Thumbnail URL to display - controlled to prevent old thumbnail flash during transitions @State var displayedThumbnailURL: URL? /// Loaded thumbnail image stored in @State to survive view re-renders without flashing @State var displayedThumbnailImage: Image? /// Whether we're in transition and should freeze the thumbnail @State var isThumbnailFrozen: Bool = false /// Whether we need to scroll to top when player height changes (during video transition) @State private var pendingScrollToTopOnHeightChange: Bool = false /// Scroll position for recommended videos carousel on ended screen @State var recommendedScrollPosition: Int? = 0 /// Task for preloading comments - cancelled when video changes @State var commentsPreloadTask: Task? /// Namespace for matchedGeometryEffect transitions @Namespace var playerNamespace // MARK: - Layout State (iOS) #if os(iOS) /// Track previous orientation to detect changes @State private var previousIsLandscape: Bool? #endif #if os(iOS) || os(macOS) /// Debug stats for overlay @State var debugStats: MPVDebugStats = MPVDebugStats() /// Timer for updating debug stats @State var debugUpdateTimer: Timer? #endif // MARK: - Computed Properties var playerService: PlayerService? { appEnvironment?.playerService } var playerState: PlayerState? { playerService?.state } var downloadManager: DownloadManager? { appEnvironment?.downloadManager } var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator } var accentColor: Color { appEnvironment?.settingsManager.accentColor.color ?? .accentColor } #if os(iOS) var inAppOrientationLock: Bool { appEnvironment?.settingsManager.inAppOrientationLock ?? false } #endif // MARK: - Comments Helpers var comments: [Comment] { playerState?.comments ?? [] } var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle } // MARK: - Queue Helpers var queue: [QueuedVideo] { playerState?.queue ?? [] } var history: [QueuedVideo] { playerState?.history ?? [] } var isQueueEnabled: Bool { appEnvironment?.settingsManager.queueEnabled ?? true } var hasQueueItems: Bool { !queue.isEmpty && isQueueEnabled } // MARK: - Player Pill Helpers var playerPillSettings: PlayerPillSettings { playerControlsLayout.effectivePlayerPillSettings } var shouldShowPlayerPill: Bool { playerPillSettings.visibility.isVisible(isWideLayout: true) && !playerPillSettings.buttons.isEmpty } // MARK: - Autoplay Helpers var isAutoPlayEnabled: Bool { (appEnvironment?.settingsManager.queueEnabled ?? true) && (appEnvironment?.settingsManager.queueAutoPlayNext ?? true) } var autoPlayCountdownDuration: Int { appEnvironment?.settingsManager.queueAutoPlayCountdown ?? 5 } var nextQueuedVideo: QueuedVideo? { queue.first } // MARK: - Playback State Helpers /// Returns current playback state info for rendering decisions var playbackInfo: PlaybackInfo { let state = playerState?.playbackState ?? .idle let isLoading = state == .loading let isIdle = state == .idle let isEnded = state == .ended let isFailed = if case .failed = state { true } else { false } let hasBackend = playerService?.currentBackend != nil && !isLoading && !isIdle && !isEnded && !isFailed return PlaybackInfo( state: state, isLoading: isLoading, isIdle: isIdle, isEnded: isEnded, isFailed: isFailed, hasBackend: hasBackend ) } // MARK: - Body var body: some View { playerContentWithOverlays .applyPlayerSheets( showingQualitySheet: $showingQualitySheet, showingPlaylistSheet: $showingPlaylistSheet, showingDownloadSheet: $showingDownloadSheet, showingDeleteDownloadAlert: $showingDeleteDownloadAlert, onStreamSelected: { stream, audioStream in switchToStream(stream, audioStream: audioStream) } ) } // MARK: - Player Content with Overlays /// Main player content with overlays and event handlers - extracted to help compiler type-checking @ViewBuilder private var playerContentWithOverlays: some View { playerContentWithCommentsOverlay .modifier(PlayerEventHandlersModifier( isCommentsExpanded: $isCommentsExpanded, scrollPosition: $scrollPosition, isPanelExpanded: $isPanelExpanded, panelExpandOffset: $panelExpandOffset, showOriginalTitle: $showOriginalTitle, isThumbnailFrozen: $isThumbnailFrozen, displayedThumbnailURL: $displayedThumbnailURL, displayedThumbnailImage: $displayedThumbnailImage, isAutoplayCancelled: $isAutoplayCancelled, pendingScrollToTopOnHeightChange: $pendingScrollToTopOnHeightChange, playerControlsLayout: $playerControlsLayout, stopAutoplayCountdown: stopAutoplayCountdown, startAutoplayCountdown: startAutoplayCountdown, startPreloadingComments: startPreloadingComments, cancelCommentsPreload: cancelCommentsPreload, isAutoPlayEnabled: isAutoPlayEnabled, nextQueuedVideo: nextQueuedVideo )) #if os(iOS) .modifier(PlayerIOSEventHandlersModifier( inAppOrientationLock: inAppOrientationLock, toggleFullscreen: toggleFullscreen, startDebugUpdates: startDebugUpdates, stopDebugUpdates: stopDebugUpdates, setupRotationMonitoring: setupRotationMonitoring, setupOrientationLockCallback: setupOrientationLockCallback )) #endif #if os(macOS) .modifier(PlayerMacOSEventHandlersModifier( startDebugUpdates: startDebugUpdates, stopDebugUpdates: stopDebugUpdates )) #endif } /// Player content with comments overlay - extracted to help compiler type-checking @ViewBuilder private var playerContentWithCommentsOverlay: some View { playerContent .accessibilityIdentifier("player.expandedSheet") .accessibilityLabel("player.expandedSheet") .playerToastOverlay() .overlay(alignment: .bottom) { expandedCommentsOverlay } .animation(.smooth(duration: 0.3), value: isCommentsExpanded) } // MARK: - Comments Overlay /// Expanded comments overlay - extracted to help compiler type-checking @ViewBuilder private var expandedCommentsOverlay: some View { GeometryReader { geometry in let availableHeight = geometry.size.height - currentPlayerHeight + geometry.safeAreaInsets.bottom let commentsMinHeight: CGFloat = 500 let shouldCoverFullSheet = availableHeight < commentsMinHeight let commentsHeight = shouldCoverFullSheet ? geometry.size.height + geometry.safeAreaInsets.bottom : availableHeight let spacerHeight = shouldCoverFullSheet ? 0 : currentPlayerHeight let isVisible = !isInWideScreenLayout && isCommentsExpanded VStack(spacing: 0) { Spacer() .frame(height: spacerHeight) ExpandedCommentsView( videoID: playerState?.currentVideo?.id.videoID ?? "", onClose: collapseComments, onDismissOffsetChanged: handleCommentsDismissOffset, onDismissGestureEnded: handleCommentsDismissGestureEnded, dismissThreshold: 30 ) .frame(height: max(0, commentsHeight)) .clipped() } .offset(y: commentsDismissOffset) .visualEffect { content, proxy in content.offset(y: isVisible ? 0 : proxy.size.height) } .opacity(isVisible ? 1 : 0) .allowsHitTesting(isVisible) .background( GeometryReader { commentsGeometry in Color.clear .onChange(of: commentsGeometry.frame(in: .global)) { _, newFrame in if isCommentsExpanded { navigationCoordinator?.commentsFrame = newFrame } } .onChange(of: isCommentsExpanded) { _, expanded in if expanded { navigationCoordinator?.commentsFrame = commentsGeometry.frame(in: .global) } else { navigationCoordinator?.commentsFrame = .zero } } } ) } .ignoresSafeArea(edges: .bottom) } // MARK: - Player Content @ViewBuilder private var playerContent: some View { GeometryReader { geometry in let wideScreen = isWideScreenLayout(size: geometry.size) ZStack(alignment: .top) { // Full-screen background - prevents content leaking during aspect ratio animation // Use black for all modes since panel-based layout uses black backgrounds Color.black.ignoresSafeArea(edges: .bottom) #if os(iOS) || os(macOS) if wideScreen { if let video = playerState?.currentVideo { // Widescreen layout with floating panel // Must ignore safe area to get full screen geometry wideScreenContent(video: video) .ignoresSafeArea(.all) } else { // Widescreen loading state - just show black with spinner Color.black .ignoresSafeArea(.all) .overlay { ProgressView() .tint(.white) .controlSize(.large) } } } else { // Portrait/standard scrolling layout standardPlayerContent(geometry: geometry) } #else // tvOS uses standard layout only standardPlayerContent(geometry: geometry) #endif } .transaction { transaction in // Disable animations during layout switch to prevent position interpolation transaction.animation = nil } .onChange(of: wideScreen) { _, newValue in isInWideScreenLayout = newValue #if os(iOS) // Detect orientation changes for panscan reset if let previous = previousIsLandscape, previous != newValue { // Landscape→portrait: panscan reset if previous && !newValue { navigationCoordinator?.pinchPanscan = 0 // Restore portrait panel visibility state navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible } // Reset panel expansion state on orientation change isPanelExpanded = false panelExpandOffset = 0 // Reset panel state to orientation defaults if newValue { // Entering landscape: reset to hidden/unpinned appEnvironment?.settingsManager.landscapeDetailsPanelVisible = false appEnvironment?.settingsManager.landscapeDetailsPanelPinned = false // Portrait panel doesn't exist in wide layout - clear flag so gesture handler allows pinch navigationCoordinator?.isPortraitPanelVisible = false } // Note: Entering portrait preserves panel visibility state // (if hidden before landscape, stays hidden after returning to portrait) } previousIsLandscape = newValue #endif } .onAppear { isInWideScreenLayout = wideScreen #if os(iOS) previousIsLandscape = wideScreen // Reset panel state on fresh player open (prevents stale state from previous session) appEnvironment?.settingsManager.landscapeDetailsPanelVisible = false appEnvironment?.settingsManager.landscapeDetailsPanelPinned = false if wideScreen { navigationCoordinator?.isPortraitPanelVisible = false } #endif } } .animation(.spring(response: 0.3, dampingFraction: 0.7), value: commentsState == .loaded && !comments.isEmpty) .sheet(isPresented: $showingQueueSheet) { QueueManagementSheet() } .sheet(isPresented: $showingErrorSheet) { ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error") } #if os(iOS) .toolbar(.hidden, for: .navigationBar) .playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible) .persistentSystemOverlays((isInWideScreenLayout || !isPortraitPanelVisible) ? .hidden : .automatic) #endif } // MARK: - Bottom Pills Overlay (iOS) #if os(iOS) /// Extracted overlay for player pill, comments pill and scroll button to help compiler type-checking @ViewBuilder private var bottomPillsOverlay: some View { GeometryReader { geometry in let hasCommentsPill = commentsState == .loaded && !comments.isEmpty let isPlaying = playerState?.playbackState == .playing // Disable play/pause when loading OR when thumbnail is still visible (buffer/frame not ready) let isPlayPauseDisabled = playerState?.playbackState == .loading || playerState?.playbackState == .buffering || !(playerState?.isFirstFrameReady ?? false) || !(playerState?.isBufferReady ?? false) let hasNext = playerState?.hasNext ?? false // On narrow devices, use smaller side buttons so the player pill gets more horizontal space let isCompactPillRow = geometry.size.width <= 390 let collapsedCommentWidth: CGFloat = isCompactPillRow ? 40 : 52 let scrollButtonWidth: CGFloat = isCompactPillRow ? 40 : 52 let pillRowSpacing: CGFloat = isCompactPillRow ? 8 : 12 VStack { Spacer() VStack(spacing: 12) { // Comments pill - on its own row (only when player pill exists) if hasCommentsPill, let firstComment = comments.first, shouldShowPlayerPill { CommentsPillView( comment: firstComment, isCollapsed: false, onTap: expandComments ) .transition(.scale.combined(with: .opacity)) } // Bottom row - centered group bottomPillsRow( hasCommentsPill: hasCommentsPill, collapsedCommentWidth: collapsedCommentWidth, pillRowSpacing: pillRowSpacing, isCompact: isCompactPillRow, isPlaying: isPlaying, hasNext: hasNext, isPlayPauseDisabled: isPlayPauseDisabled, scrollButtonWidth: scrollButtonWidth, availableWidth: geometry.size.width ) } .frame(maxWidth: .infinity) .padding(.horizontal, 16) .padding(.bottom, 24) .background { // Block touches on description links behind pills area // Prevents accidental link taps when trying to tap pills Color.clear .contentShape(Rectangle()) } } .animation(.spring(response: 0.35, dampingFraction: 0.8), value: hasCommentsPill) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: shouldShowPlayerPill) .animation(.easeInOut(duration: 0.2), value: showScrollButton) } .ignoresSafeArea(edges: .bottom) } /// Bottom row of pills (comments, player pill, scroll button) - extracted to help compiler @ViewBuilder private func bottomPillsRow( hasCommentsPill: Bool, collapsedCommentWidth: CGFloat, pillRowSpacing: CGFloat, isCompact: Bool, isPlaying: Bool, hasNext: Bool, isPlayPauseDisabled: Bool, scrollButtonWidth: CGFloat, availableWidth: CGFloat ) -> some View { // Calculate max pill width: available width minus placeholders and spacing // Layout: [16 padding] [left] [spacing] [PILL] [spacing] [right] [16 padding] let edgeElementsWidth: CGFloat = 32 + collapsedCommentWidth + scrollButtonWidth + (pillRowSpacing * 2) let maxPillWidth = max(availableWidth - edgeElementsWidth, 150) HStack(spacing: pillRowSpacing) { // Left side: comments pill or placeholder if hasCommentsPill, let firstComment = comments.first { if !shouldShowPlayerPill { // No player pill: expanded comments in bottom row CommentsPillView( comment: firstComment, isCollapsed: false, fillWidth: true, onTap: expandComments ) .frame(maxWidth: 400) .frame(maxWidth: .infinity, alignment: .center) .transition(.scale.combined(with: .opacity)) } else { // Placeholder (expanded comments on its own row) Color.clear .frame(width: collapsedCommentWidth, height: collapsedCommentWidth) } } else if shouldShowPlayerPill { Color.clear .frame(width: collapsedCommentWidth, height: collapsedCommentWidth) } // Player pill if shouldShowPlayerPill { PlayerPillView( settings: playerPillSettings, maxWidth: maxPillWidth, isPlaying: isPlaying, hasNext: hasNext, queueCount: queue.count, queueModeIcon: playerState?.queueMode.icon ?? "list.bullet", isPlayPauseDisabled: isPlayPauseDisabled, isOrientationLocked: inAppOrientationLock, video: playerState?.currentVideo, playbackRate: playerState?.rate ?? .x1, showingPlaylistSheet: $showingPlaylistSheet, onQueueTap: { showingQueueSheet = true }, onPrevious: { Task { await playerService?.playPrevious() } }, onPlayPause: { playerService?.togglePlayPause() }, onNext: { playNextInQueue() }, onSeek: { signedSeconds in if signedSeconds >= 0 { playerService?.seekForward(by: signedSeconds) } else { playerService?.seekBackward(by: -signedSeconds) } }, onClose: { appEnvironment?.queueManager.clearQueue() playerService?.stop() navigationCoordinator?.isPlayerExpanded = false }, onAirPlay: { /* AirPlay - handled by system */ }, onPiP: { if let mpvBackend = playerService?.currentBackend as? MPVBackend { mpvBackend.togglePiP() } }, onOrientationLock: { appEnvironment?.settingsManager.inAppOrientationLock.toggle() }, onRateChanged: { rate in playerState?.rate = rate playerService?.currentBackend?.rate = Float(rate.rawValue) } ) } // Scroll button or placeholder if showScrollButton { Button { scrollToTop() } label: { Image(systemName: "arrow.up") .font(.system(size: isCompact ? 16 : 18, weight: .semibold)) .foregroundStyle(.primary) .frame(width: isCompact ? 28 : 32, height: isCompact ? 28 : 32) .padding(isCompact ? 6 : 10) .contentShape(Circle()) } .buttonStyle(.plain) .glassBackground(.regular, in: .circle, fallback: .thinMaterial) .shadow(color: .black.opacity(0.1), radius: 8, y: 2) .contentShape(Circle()) .transition(.scale.combined(with: .opacity)) } else if shouldShowPlayerPill { Color.clear .frame(width: scrollButtonWidth, height: scrollButtonWidth) } } } #endif } // MARK: - Player Sheet Modifiers /// View extension for applying player sheets - extracted to help compiler type-checking private extension View { @ViewBuilder func applyPlayerSheets( showingQualitySheet: Binding, showingPlaylistSheet: Binding, showingDownloadSheet: Binding, showingDeleteDownloadAlert: Binding, onStreamSelected: @escaping (Stream, Stream?) -> Void ) -> some View { modifier(PlayerSheetsModifier( showingQualitySheet: showingQualitySheet, showingPlaylistSheet: showingPlaylistSheet, showingDownloadSheet: showingDownloadSheet, showingDeleteDownloadAlert: showingDeleteDownloadAlert, onStreamSelected: onStreamSelected )) } } /// ViewModifier for player sheets - extracted to help compiler type-checking private struct PlayerSheetsModifier: ViewModifier { @Environment(\.appEnvironment) var appEnvironment @Binding var showingQualitySheet: Bool @Binding var showingPlaylistSheet: Bool @Binding var showingDownloadSheet: Bool @Binding var showingDeleteDownloadAlert: Bool let onStreamSelected: (Stream, Stream?) -> Void var playerService: PlayerService? { appEnvironment?.playerService } var playerState: PlayerState? { playerService?.state } var downloadManager: DownloadManager? { appEnvironment?.downloadManager } func body(content: Content) -> some View { content .sheet(isPresented: $showingQualitySheet) { qualitySelectorSheet } .sheet(isPresented: $showingPlaylistSheet) { if let video = playerState?.currentVideo { PlaylistSelectorSheet(video: video) } } #if !os(tvOS) .sheet(isPresented: $showingDownloadSheet) { downloadSheet } .alert( String(localized: "player.deleteDownload.title"), isPresented: $showingDeleteDownloadAlert ) { Button(String(localized: "player.deleteDownload.cancel"), role: .cancel) {} Button(String(localized: "player.deleteDownload.delete"), role: .destructive) { if let video = playerState?.currentVideo, let download = downloadManager?.download(for: video.id) { Task { await downloadManager?.delete(download) } } } } message: { Text(String(localized: "player.deleteDownload.message")) } #endif } @ViewBuilder private var qualitySelectorSheet: some View { if let playerService { let supportedFormats = playerService.currentBackendType.supportedFormats let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false QualitySelectorView( streams: playerService.availableStreams.filter { stream in let format = StreamFormat.detect(from: stream) // Filter out DASH streams if disabled in settings if format == .dash && !dashEnabled { return false } return supportedFormats.contains(format) }, captions: playerService.availableCaptions, currentStream: playerState?.currentStream, currentAudioStream: playerState?.currentAudioStream, currentCaption: playerService.currentCaption, isLoading: playerService.availableStreams.isEmpty && playerState?.playbackState == .loading, currentDownload: playerService.currentDownload, isLoadingOnlineStreams: playerService.isLoadingOnlineStreams, localCaptionURL: playerService.currentDownload.flatMap { download in guard let path = download.localCaptionPath else { return nil } return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path) }, currentRate: playerState?.rate ?? .x1, isControlsLocked: playerState?.isControlsLocked ?? false, onStreamSelected: { stream, audioStream in onStreamSelected(stream, audioStream) }, onCaptionSelected: { caption in playerService.loadCaption(caption) }, onLoadOnlineStreams: { Task { await playerService.loadOnlineStreams() } }, onSwitchToOnlineStream: { stream, audioStream in Task { await playerService.switchToOnlineStream(stream, audioStream: audioStream) } }, onRateChanged: { rate in playerState?.rate = rate playerService.currentBackend?.rate = Float(rate.rawValue) }, onLockToggled: { locked in playerState?.isControlsLocked = locked } ) } } #if !os(tvOS) @ViewBuilder private var downloadSheet: some View { if let video = playerState?.currentVideo, let playerService { DownloadQualitySheet( video: video, streams: playerService.availableStreams, captions: playerService.availableCaptions, dislikeCount: playerState?.dislikeCount ) } } #endif } // MARK: - Player Event Handlers Modifier /// ViewModifier for common player event handlers - extracted to help compiler type-checking private struct PlayerEventHandlersModifier: ViewModifier { @Environment(\.appEnvironment) var appEnvironment @Binding var isCommentsExpanded: Bool @Binding var scrollPosition: ScrollPosition @Binding var isPanelExpanded: Bool @Binding var panelExpandOffset: CGFloat @Binding var showOriginalTitle: Bool @Binding var isThumbnailFrozen: Bool @Binding var displayedThumbnailURL: URL? @Binding var displayedThumbnailImage: Image? @Binding var isAutoplayCancelled: Bool @Binding var pendingScrollToTopOnHeightChange: Bool @Binding var playerControlsLayout: PlayerControlsLayout let stopAutoplayCountdown: () -> Void let startAutoplayCountdown: () -> Void let startPreloadingComments: () -> Void let cancelCommentsPreload: () -> Void let isAutoPlayEnabled: Bool let nextQueuedVideo: QueuedVideo? var playerState: PlayerState? { appEnvironment?.playerService.state } var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator } func body(content: Content) -> some View { content .onChange(of: isCommentsExpanded) { _, newValue in navigationCoordinator?.isCommentsExpanded = newValue } .onChange(of: playerState?.currentVideo?.id) { _, _ in handleVideoChange() } .onChange(of: playerState?.playbackState) { oldState, newState in handlePlaybackStateChange(oldState: oldState, newState: newState) } .onChange(of: playerState?.isBufferReady) { oldValue, newValue in if newValue == true && oldValue != true { startPreloadingComments() // Unfreeze thumbnail now that player is ready isThumbnailFrozen = false } } .onChange(of: navigationCoordinator?.scrollPlayerIntoViewTrigger) { _, _ in handleScrollIntoView() } .onAppear { handleOnAppear() } .task { await loadPlayerControlsLayout() } .onReceive(NotificationCenter.default.publisher(for: .playerControlsActivePresetDidChange)) { _ in Task { await loadPlayerControlsLayout() } } .onReceive(NotificationCenter.default.publisher(for: .playerControlsPresetsDidChange)) { _ in Task { await loadPlayerControlsLayout() } } } private func handleVideoChange() { cancelCommentsPreload() pendingScrollToTopOnHeightChange = true scrollPosition.scrollTo(y: 0) var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { playerState?.comments = [] playerState?.commentsState = .idle playerState?.commentsContinuation = nil isCommentsExpanded = false navigationCoordinator?.commentsFrame = .zero isPanelExpanded = false panelExpandOffset = 0 } showOriginalTitle = false stopAutoplayCountdown() isAutoplayCancelled = false // Clear loaded image so new video gets fresh thumbnail displayedThumbnailImage = nil // Capture thumbnail URL immediately and freeze to prevent flash during details load displayedThumbnailURL = playerState?.currentVideo?.bestThumbnail?.url isThumbnailFrozen = true } private func handlePlaybackStateChange(oldState: PlaybackState?, newState: PlaybackState?) { if newState == .ended { if isAutoPlayEnabled && nextQueuedVideo != nil { startAutoplayCountdown() } } else if oldState == .ended { stopAutoplayCountdown() isAutoplayCancelled = false } } private func handleScrollIntoView() { let animationDuration = 0.25 withAnimation(.easeOut(duration: animationDuration)) { scrollPosition.scrollTo(y: 0) } DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) { [weak navigationCoordinator] in navigationCoordinator?.isPlayerScrollAnimating = false } } private func handleOnAppear() { if playerState?.isBufferReady == true && playerState?.commentsState == .idle { startPreloadingComments() } if playerState?.playbackState == .ended, isAutoPlayEnabled, nextQueuedVideo != nil, !isAutoplayCancelled { startAutoplayCountdown() } } private func loadPlayerControlsLayout() async { if let service = appEnvironment?.playerControlsLayoutService { let layout = await service.activeLayout() playerControlsLayout = layout GlobalLayoutSettings.cached = layout.globalSettings MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings } } } // MARK: - iOS Event Handlers Modifier #if os(iOS) /// ViewModifier for iOS-specific player event handlers - extracted to help compiler type-checking private struct PlayerIOSEventHandlersModifier: ViewModifier { @Environment(\.appEnvironment) var appEnvironment let inAppOrientationLock: Bool let toggleFullscreen: () -> Void let startDebugUpdates: () -> Void let stopDebugUpdates: () -> Void let setupRotationMonitoring: () -> Void let setupOrientationLockCallback: () -> Void var playerState: PlayerState? { appEnvironment?.playerService.state } var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator } func body(content: Content) -> some View { content .onAppear { setupRotationMonitoring() setupOrientationLockCallback() if inAppOrientationLock { OrientationManager.shared.lock() } } .onDisappear { DeviceRotationManager.shared.stopMonitoring() DeviceRotationManager.shared.isOrientationLocked = nil OrientationManager.shared.unlock() stopDebugUpdates() } .onChange(of: inAppOrientationLock) { _, isLocked in if isLocked { OrientationManager.shared.lock() } else { OrientationManager.shared.unlock() } } .onChange(of: navigationCoordinator?.pendingFullscreenToggle) { _, _ in toggleFullscreen() } .onChange(of: playerState?.showDebugOverlay) { _, isVisible in if isVisible == true { startDebugUpdates() } else { stopDebugUpdates() } } } } #endif // MARK: - macOS Event Handlers Modifier #if os(macOS) /// ViewModifier for macOS-specific player event handlers - extracted to help compiler type-checking private struct PlayerMacOSEventHandlersModifier: ViewModifier { @Environment(\.appEnvironment) var appEnvironment let startDebugUpdates: () -> Void let stopDebugUpdates: () -> Void var playerState: PlayerState? { appEnvironment?.playerService.state } func body(content: Content) -> some View { content .onChange(of: playerState?.showDebugOverlay) { _, isVisible in if isVisible == true { startDebugUpdates() } else { stopDebugUpdates() } } .onChange(of: playerState?.videoAspectRatio) { oldValue, newValue in handleAspectRatioChange(oldValue: oldValue, newValue: newValue) } .onChange(of: playerState?.currentVideo?.id) { oldValue, newValue in handleVideoChangeForResize(oldValue: oldValue, newValue: newValue) } .onAppear { handleMacOSAppear() } } private func handleAspectRatioChange(oldValue: Double?, newValue: Double?) { guard let newValue, newValue > 0 else { return } guard appEnvironment?.settingsManager.playerSheetAutoResize == true else { return } let shouldAnimate = oldValue != nil Task { @MainActor in try? await Task.sleep(for: .milliseconds(100)) ExpandedPlayerWindowManager.shared.resizeToFitAspectRatio( newValue, animated: shouldAnimate ) } } private func handleVideoChangeForResize(oldValue: Video.ID?, newValue: Video.ID?) { guard oldValue != nil, newValue != nil else { return } guard appEnvironment?.settingsManager.playerSheetAutoResize == true else { return } Task { @MainActor in try? await Task.sleep(for: .milliseconds(500)) if let aspectRatio = playerState?.videoAspectRatio, aspectRatio > 0 { ExpandedPlayerWindowManager.shared.resizeToFitAspectRatio( aspectRatio, animated: true ) } } } private func handleMacOSAppear() { if appEnvironment?.settingsManager.playerSheetAutoResize == true, let aspectRatio = playerState?.videoAspectRatio, aspectRatio > 0 { ExpandedPlayerWindowManager.shared.resizeToFitAspectRatio(aspectRatio, animated: false) } } } #endif // MARK: - Preview #Preview("Player Sheet") { Text("Tap to open") .sheet(isPresented: .constant(true)) { ExpandedPlayerSheet() } .appEnvironment(.preview) } #endif