// // PlayerService.swift // Yattee // // Service managing video playback with pluggable backends. // import Foundation import AVFoundation import Combine import SwiftUI /// Protocol for player service delegate callbacks. @MainActor protocol PlayerServiceDelegate: AnyObject { func playerService(_ service: PlayerService, didUpdateTime time: TimeInterval) func playerService(_ service: PlayerService, didChangeState state: PlaybackState) func playerService(_ service: PlayerService, didEncounterError error: Error) func playerService(_ service: PlayerService, shouldSkipSegment segment: SponsorBlockSegment) -> Bool func playerServiceDidFinishPlaying(_ service: PlayerService) } /// Main service for video playback. @MainActor @Observable final class PlayerService { // MARK: - Properties /// The current player backend. private(set) var currentBackend: (any PlayerBackend)? /// The type of backend currently in use. var currentBackendType: PlayerBackendType { currentBackend?.backendType ?? preferredBackendType } /// MPV version information (available when MPV backend is initialized). var mpvVersionInfo: MPVVersionInfo? { (currentBackend as? MPVBackend)?.versionInfo } /// User's preferred backend type (reads directly from settings for live updates). var preferredBackendType: PlayerBackendType { settingsManager?.preferredBackend ?? .mpv } /// Observable player state. let state: PlayerState /// Delegate for callbacks. weak var delegate: PlayerServiceDelegate? /// Available streams for the current video. private(set) var availableStreams: [Stream] = [] /// Available captions for the current video. private(set) var availableCaptions: [Caption] = [] /// Currently loaded caption. private(set) var currentCaption: Caption? /// The current download being played, if any. private(set) var currentDownload: Download? /// Whether we're currently playing downloaded content. var isPlayingDownloadedContent: Bool { currentDownload != nil } /// Whether online streams are currently being loaded. private(set) var isLoadingOnlineStreams: Bool = false // MARK: - Dependencies private let httpClient: HTTPClient private let contentService: ContentService private let sponsorBlockAPI: SponsorBlockAPI private let returnYouTubeDislikeAPI: ReturnYouTubeDislikeAPI private let dataManager: DataManager private let backendFactory: BackendFactory private let backendSwitcher: BackendSwitcher private weak var settingsManager: SettingsManager? private weak var downloadManager: DownloadManager? private weak var navigationCoordinator: NavigationCoordinator? private weak var connectivityMonitor: ConnectivityMonitor? private weak var toastManager: ToastManager? private weak var queueManager: QueueManager? private weak var playerControlsLayoutService: PlayerControlsLayoutService? private weak var handoffManager: HandoffManager? // MARK: - Private State /// Current scene phase, updated by handleScenePhase private var currentScenePhase: ScenePhase = .active private var progressSaveTimer: Timer? private let progressSaveInterval: TimeInterval = 5 /// Now Playing service for Control Center/Lock Screen. private let nowPlayingService = NowPlayingService() /// Service to prevent system sleep during playback. private let sleepPreventionService = SleepPreventionService() /// Tracks the last skipped segment to prevent duplicate skips. private var lastSkippedSegmentID: String? /// Tracks if the current video ended naturally (reached EOF). /// Used to prevent saving progress when switching to next video after natural completion. private var videoEndedNaturally = false /// Current playback task - cancelled when video is closed or new video is opened. private var currentPlayTask: Task? /// Video ID that we're currently loading - used to ignore stale time updates from previous video. private var loadingVideoID: VideoID? /// Counter for stream refresh attempts to prevent infinite refresh loops. private var streamRefreshAttempts = 0 private let maxStreamRefreshAttempts = 2 /// Tracks if playback was interrupted by system (phone call, alarm, etc.) /// Used to auto-resume when interruption ends. private var wasInterrupted = false /// Tracks if audio session interruption observer has been registered. private var hasRegisteredInterruptionObserver = false // MARK: - Initialization init( httpClient: HTTPClient, contentService: ContentService, dataManager: DataManager, backendFactory: BackendFactory? = nil ) { self.httpClient = httpClient self.contentService = contentService self.dataManager = dataManager self.sponsorBlockAPI = SponsorBlockAPI(httpClient: httpClient) self.returnYouTubeDislikeAPI = ReturnYouTubeDislikeAPI(httpClient: httpClient) self.state = PlayerState() let factory = backendFactory ?? BackendFactory() self.backendFactory = factory // Note: settingsManager is set later via setSettingsManager, backendSwitcher will use it once available self.backendSwitcher = BackendSwitcher(backendFactory: factory, settingsManager: nil) // Link now playing service back to player nowPlayingService.playerService = self // Pre-warm backends asynchronously (non-blocking) // This starts OpenGL initialization in background immediately at app launch Task { await factory.prewarmAllBackends() } // Listen for memory warnings to drain backend pool #if os(iOS) || os(tvOS) NotificationCenter.default.addObserver( forName: .memoryWarning, object: nil, queue: .main ) { [weak factory] _ in Task { @MainActor [weak factory] in factory?.drainPool() } } #endif } deinit { MainActor.assumeIsolated { cleanup() } } // MARK: - Public Methods /// Prepares a video for playback without loading streams. /// Shows the video in the player with a play button overlay. /// Call `play(video:)` to actually start loading and playing. /// - Parameter video: The video to prepare func prepare(video: Video) { LoggingService.shared.logPlayer("Preparing video: \(video.id.id)") // Clear streams availableStreams = [] // Set video in state but keep idle (not loading) state.setCurrentVideo(video, stream: nil) state.setPlaybackState(.idle) } /// Plays a video with optional starting time. /// - Parameters: /// - video: The video to play /// - stream: Optional specific stream to use (if provided, skips fetching streams from API) /// - audioStream: Optional separate audio stream (for video-only streams) /// - startTime: Optional start time in seconds func play(video: Video, stream: Stream? = nil, audioStream: Stream? = nil, startTime: TimeInterval? = nil) async { // Set up audio session when playback actually starts (not at app launch) setupAudioSession() // Save progress and sync for previous video before switching // Skip if video ended naturally - 100% was already saved in backendDidFinishPlaying if state.currentVideo != nil && state.currentVideo?.id != video.id && !videoEndedNaturally { saveProgressAndSync() } // Reset flag for the new video videoEndedNaturally = false // Clear sponsor block state from previous video state.sponsorSegments = [] state.currentSegment = nil lastSkippedSegmentID = nil // Reset stream refresh counter for new video streamRefreshAttempts = 0 // Clear caption state from previous video // (will be set later if this video has captions) currentCaption = nil availableCaptions = [] // Mark that we're loading this video - time updates will be ignored until loading completes loadingVideoID = video.id state.setPlaybackState(.loading) state.isFirstFrameReady = false // Reset until first frame of new video is rendered state.isBufferReady = false // Reset until buffer is ready for smooth playback state.setCurrentVideo(video, stream: stream, audioStream: audioStream) // Start fetching SponsorBlock segments early (in parallel with stream loading) // so we have them ready before playback starts var sponsorBlockTask: Task? if stream == nil, case .global = video.id.source, settingsManager?.sponsorBlockEnabled == true { sponsorBlockTask = Task { await fetchSponsorBlockSegments(for: video.id.videoID) } } do { let selectedStream: Stream let selectedAudioStream: Stream? let backendType: PlayerBackendType // If a stream is provided (e.g., local/downloaded file or quality switch), use it directly if let providedStream = stream { selectedStream = providedStream selectedAudioStream = audioStream backendType = preferredBackendType // Only set availableStreams if empty (preserve existing streams during quality switch) if availableStreams.isEmpty { self.availableStreams = [providedStream] } state.setCurrentVideo(video, stream: providedStream, audioStream: audioStream) // Mark details as loaded since we're using the video as-is state.videoDetailsState = .loaded lockDurationIfNeeded(for: video, stream: providedStream) // Load local storyboard if this is a downloaded video if let downloadManager, let download = downloadManager.download(for: video.id), let storyboard = download.storyboard, let storyboardPath = download.localStoryboardPath { let storyboardDir = downloadManager.downloadsDirectory().appendingPathComponent(storyboardPath) let localStoryboard = Storyboard.localStoryboard(from: storyboard, localDirectory: storyboardDir) Task { await StoryboardService.shared.clearCache() } state.storyboards = [localStoryboard] LoggingService.shared.logPlayer("Loaded local storyboard for \(video.id.id)") } } else { // Mark that we're loading video details from API state.videoDetailsState = .loading // Fetch full video details (includes description), streams, captions, and storyboards in one API call let (fullVideo, streams, captions, storyboards) = try await fetchVideoStreamsAndCaptionsAndStoryboards(for: video) // Check for cancellation after network fetch try Task.checkCancellation() self.availableStreams = streams self.availableCaptions = captions // Update storyboards and clear cached sprite sheets Task { await StoryboardService.shared.clearCache() } state.storyboards = storyboards LoggingService.shared.logPlayer("Loaded \(storyboards.count) storyboards, preferred: \(state.preferredStoryboard?.width ?? 0)x\(state.preferredStoryboard?.height ?? 0)") // Select best stream for preferred backend let selection = selectStreamAndBackend(from: streams) guard let selected = selection.stream else { throw APIError.noStreams } selectedStream = selected selectedAudioStream = selection.audioStream backendType = selection.backend // Update state with full video details and selected stream state.setCurrentVideo(fullVideo, stream: selectedStream, audioStream: selectedAudioStream) state.videoDetailsState = .loaded lockDurationIfNeeded(for: fullVideo, stream: selectedStream) // Notify observers that full video details are now available NotificationCenter.default.post(name: .videoDetailsDidLoad, object: nil) // Auto-load preferred subtitle if set and will use MPV backend if backendType == .mpv, let preferredLanguage = settingsManager?.preferredSubtitlesLanguage, !preferredLanguage.isEmpty { // Find matching caption (will be loaded after backend is ready) if let preferredCaption = captions.first(where: { caption in caption.baseLanguageCode == preferredLanguage || caption.languageCode.hasPrefix(preferredLanguage) }) { // Store for later loading after backend is created currentCaption = preferredCaption } } } // Check for cancellation before loading stream try Task.checkCancellation() // Calculate seek time BEFORE loading to know if we need to prepare for initial seek // This prevents the thumbnail from hiding before the resume position is reached let seekTime: TimeInterval // Use state.duration as fallback for quality switching when video.duration might be 0 let effectiveDuration = video.duration > 0 ? video.duration : state.duration let completionThreshold = effectiveDuration * 0.9 let savedProgress = dataManager.watchProgress(for: video.id.videoID) LoggingService.shared.logPlayer("Replay check: savedProgress=\(savedProgress ?? -1), startTime=\(startTime ?? -1), duration=\(video.duration), threshold=\(completionThreshold)") if let startTime { // Explicit startTime provided - use it (0 means play from beginning, >0 means resume) // For quality switching with startTime > 0, honor the time unless video was completed if startTime > 0 && completionThreshold > 0 && startTime >= completionThreshold { seekTime = 0 // Video was completed, start over } else { seekTime = startTime } } else if let savedProgress, savedProgress > 0, savedProgress < completionThreshold { // No explicit startTime - use saved progress if video wasn't completed seekTime = savedProgress } else { // No saved progress or video was completed seekTime = 0 } // Create or switch backend if needed let backend = try await ensureBackend(type: backendType) // If we're going to seek after load, tell the backend to defer ready callbacks // until the seek completes. This prevents showing a flash of the video at position 0 // before jumping to the resume position. if seekTime > 0 { backend.prepareForInitialSeek() } // Apply volume based on volume mode setting from active preset let volumeMode: VolumeMode if let layoutService = playerControlsLayoutService { let layout = await layoutService.activeLayout() volumeMode = layout.globalSettings.volumeMode } else { volumeMode = GlobalLayoutSettings.cached.volumeMode } if volumeMode == .mpv { // In-app mode: use persisted volume backend.volume = settingsManager?.playerVolume ?? 1.0 state.volume = backend.volume } else { // System mode: set MPV to max, let device control volume backend.volume = 1.0 state.volume = 1.0 } // Load stream // Pass EDL setting (MPV-specific: combines video+audio into single virtual file for unified caching) let useEDL = settingsManager?.mpvUseEDLStreams ?? true try await backend.load(stream: selectedStream, audioStream: selectedAudioStream, autoplay: false, useEDL: useEDL) // Check for cancellation after stream load completes try Task.checkCancellation() await backend.seek(to: seekTime, showLoading: false) // Wait for player sheet animation to complete before starting playback await navigationCoordinator?.waitForPlayerSheetAnimation() // Wait for SponsorBlock segments (started earlier in parallel with stream loading) // This ensures we have segments before playback starts, so intro skips can show loading await sponsorBlockTask?.value // Resolve chapters after SponsorBlock segments are available // Uses hierarchy: SponsorBlock chapters > description parsing let videoForChapters = state.currentVideo ?? video resolveChapters(for: videoForChapters) // Ensure audio session is active before setting Now Playing (required for tvOS MPV) #if os(iOS) || os(tvOS) if let mpvBackend = backend as? MPVBackend { LoggingService.shared.logPlayer("Ensuring audio session active before Now Playing setup") mpvBackend.ensureAudioSessionActive() } #endif LoggingService.shared.logPlayer("Setting up Now Playing info before backend.play()") // Update Now Playing info BEFORE starting playback (tvOS requires this order) // The system listens to the play event to trigger Now Playing display, // so metadata must be established before playback begins. let videoForNowPlaying = state.currentVideo ?? video nowPlayingService.updateNowPlaying( video: videoForNowPlaying, currentTime: seekTime, duration: videoForNowPlaying.duration, isPlaying: true ) // Load artwork asynchronously (can happen in parallel with playback) // For downloaded videos, use local thumbnail if available for offline playback Task { [downloadManager] in var localThumbnailURL: URL? if let downloadManager, let download = downloadManager.download(for: videoForNowPlaying.id), let localThumbnailPath = download.localThumbnailPath { localThumbnailURL = downloadManager.downloadsDirectory().appendingPathComponent(localThumbnailPath) } await nowPlayingService.loadArtwork( from: videoForNowPlaying.bestThumbnail?.url, localPath: localThumbnailURL ) } // Start playback LoggingService.shared.logPlayer("Calling backend.play(), playbackState: \(state.playbackState)") loadingVideoID = nil // Clear loading flag - time updates are now valid sleepPreventionService.preventSleep() // For MPV backend, wait for sufficient buffer before starting playback // This prevents the brief pause/stutter that occurs when MPV starts playing // before enough content is buffered // Skip buffer wait for local files - they don't need network buffering let isLocalFile = selectedStream.url.isFileURL if !isLocalFile, let mpvBackend = backend as? MPVBackend { let bufferTime = settingsManager?.mpvBufferSeconds ?? SettingsManager.defaultMpvBufferSeconds _ = await mpvBackend.waitForBuffer(minimumBuffer: bufferTime) } // Buffer is ready - thumbnail can now hide state.isBufferReady = true state.bufferProgress = nil // Clear buffer progress since buffering is complete // Give SwiftUI time to process the isBufferReady state change and complete // the thumbnail fade-out animation (200ms) before audio starts playing. // Without this, audio may start while thumbnail is still visible. try? await Task.sleep(nanoseconds: 250_000_000) // 250ms (animation is 200ms) backend.play() // Update Handoff activity for cross-device continuation handoffManager?.updateActivity(for: .video(.id(video.id))) // Fetch dislike counts for YouTube videos (only for online content) // Note: SponsorBlock is fetched earlier in parallel with stream loading // Note: Captions are now fetched together with video details and streams if stream == nil, case .global = video.id.source { await fetchReturnYouTubeDislikeCounts(for: video.id.videoID) } // Load preferred subtitle if one was selected earlier (for MPV backend) if let caption = currentCaption, let mpvBackend = backend as? MPVBackend { mpvBackend.loadCaption(caption) LoggingService.shared.logPlayer("Auto-loaded preferred subtitle: \(caption.displayName)") } // Start progress saving startProgressSaveTimer() // Notify queue manager that video started (for proactive continuation loading) notifyVideoStarted() LoggingService.shared.logPlayer("Playback started: \(video.id.id)", details: "Stream: \(selectedStream.qualityLabel)") } catch is CancellationError { // Load was cancelled because a new video was selected - don't report as error LoggingService.shared.logPlayer("Playback cancelled: \(video.id.id)") sleepPreventionService.allowSleep() } catch { LoggingService.shared.logPlayerError("Playback failed: \(video.id.id)", error: error) sleepPreventionService.allowSleep() state.videoDetailsState = .error state.setPlaybackState(.failed(error)) delegate?.playerService(self, didEncounterError: error) // Auto-skip to next video if queue is not empty and player is not visible await handlePlaybackErrorAutoSkip() } } /// Pauses playback. /// - Parameter shouldSaveProgress: Whether to save watch progress. Set to `false` when video has /// already ended (100% was saved in `backendDidFinishPlaying`). func pause(saveProgress shouldSaveProgress: Bool = true) { sleepPreventionService.allowSleep() currentBackend?.pause() state.setPlaybackState(.paused) if shouldSaveProgress { saveProgress() } nowPlayingService.updatePlaybackRate(isPlaying: false, currentTime: state.currentTime) } /// Resumes playback. func resume() { sleepPreventionService.preventSleep() currentBackend?.play() state.setPlaybackState(.playing) nowPlayingService.updatePlaybackRate(isPlaying: true, currentTime: state.currentTime) } /// Toggles play/pause. func togglePlayPause() { if state.playbackState == .playing { pause() } else if state.playbackState == .ended { // Restart from beginning when video has ended Task { await seek(to: 0) resume() } } else { resume() } } /// Stops playback and clears the player. func stop() { sleepPreventionService.allowSleep() if let video = state.currentVideo { LoggingService.shared.logPlayer("Stopping playback: \(video.id.id)") } // Cancel any ongoing stream loading currentPlayTask?.cancel() currentPlayTask = nil saveProgressAndSync() cleanup() // Clear Handoff activity handoffManager?.invalidateCurrentActivity() // Clear video first, then reset (reset clears isClosingVideo) state.setCurrentVideo(nil, stream: nil) state.reset() state.clearHistory() availableStreams = [] availableCaptions = [] folderFilesCache.removeAll() downloadedSubtitlesCache.removeAll() preDownloadedSubtitleFolders.removeAll() cleanupAllTempSubtitles() currentCaption = nil lastSkippedSegmentID = nil nowPlayingService.clearNowPlaying() } /// Seeks to a specific time. /// - Parameters: /// - time: The time to seek to in seconds /// - showLoading: If true, show loading state during seek (e.g., for early SponsorBlock skips) func seek(to time: TimeInterval, showLoading: Bool = false) async { state.isSeeking = true await currentBackend?.seek(to: time, showLoading: showLoading) state.isSeeking = false state.currentTime = time } /// Seeks forward by a duration. func seekForward(by seconds: TimeInterval = 10) { state.isSeeking = true let newTime = state.currentTime + seconds Task { await currentBackend?.seek(to: newTime, showLoading: false) state.isSeeking = false } } /// Seeks backward by a duration. func seekBackward(by seconds: TimeInterval = 10) { state.isSeeking = true let newTime = max(state.currentTime - seconds, 0) Task { await currentBackend?.seek(to: newTime, showLoading: false) state.isSeeking = false } } /// Seeks by a duration in the specified direction. /// - Parameters: /// - seconds: Number of seconds to seek. /// - direction: Direction to seek (forward or backward). func seek(seconds: TimeInterval, direction: SeekDirection) { switch direction { case .forward: seekForward(by: seconds) case .backward: seekBackward(by: seconds) } } /// Handles scene phase changes for background playback support. func handleScenePhase(_ phase: ScenePhase) { currentScenePhase = phase // Allow sleep when entering background, re-enable when returning to foreground if playing if phase == .background { sleepPreventionService.allowSleep() } else if phase == .active && state.playbackState == .playing { sleepPreventionService.preventSleep() } #if os(macOS) // On macOS, don't treat main window closing as background when the player window is still visible // The scenePhase changes to .background when the main window closes, but if the player window // is still open, we should continue playing video normally if phase == .background && ExpandedPlayerWindowManager.shared.isPresented { LoggingService.shared.debug("PlayerService: Ignoring background phase - player window is still visible", category: .player) return } #endif let backgroundEnabled = settingsManager?.backgroundPlaybackEnabled ?? true #if os(iOS) let isPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false #else let isPiPActive = false #endif currentBackend?.handleScenePhase(phase, backgroundEnabled: backgroundEnabled, isPiPActive: isPiPActive) } /// Plays the next video in queue, respecting the current queue mode. func playNext() async { // Pause without saving progress if video ended - 100% was already saved in backendDidFinishPlaying pause(saveProgress: state.playbackState != .ended) // Handle queue mode behavior switch state.queueMode { case .repeatOne: // Restart current video await seek(to: 0) resume() return case .repeatAll: // If queue is empty but we have history, recycle history back to queue if state.queue.isEmpty && !state.history.isEmpty { state.recycleHistoryToQueue() } // Fall through to normal advance behavior case .shuffle: // Pick random video from queue guard let next = state.advanceQueueShuffle() else { return } pushCurrentToHistoryIfNeeded() await playQueuedVideo(next) return case .normal: break // Use default behavior below } // Normal/repeatAll advance behavior guard let next = state.advanceQueue() else { return } pushCurrentToHistoryIfNeeded() await playQueuedVideo(next) } /// Pushes current video to history if not in incognito mode. private func pushCurrentToHistoryIfNeeded() { guard let currentVideo = state.currentVideo, settingsManager?.incognitoModeEnabled != true, settingsManager?.saveWatchHistory != false else { return } let historyItem = QueuedVideo( video: currentVideo, stream: state.currentStream, audioStream: state.currentAudioStream, startTime: state.currentTime ) state.pushToHistory(historyItem) } /// Plays a queued video with its stream and caption info. /// Always starts from the beginning (0) since queue items should play fresh. /// Always prefers local downloaded content over pre-loaded network streams. /// For media browser videos, resolves stream and captions on-demand. private func playQueuedVideo(_ queuedVideo: QueuedVideo) async { // Check if this is a media source video needing on-demand resolution // Uses unified method that fetches folder contents dynamically - works from any playback source LoggingService.shared.debug("[SubtitleDebug] playQueuedVideo called, isFromMediaSource=\(queuedVideo.video.isFromMediaSource), videoID=\(queuedVideo.video.id.videoID)", category: .player) if queuedVideo.video.isFromMediaSource { LoggingService.shared.debug("[SubtitleDebug] Calling resolveMediaSourceStream", category: .player) do { let (stream, captions) = try await resolveMediaSourceStream(for: queuedVideo.video) LoggingService.shared.debug("[SubtitleDebug] resolveMediaSourceStream succeeded with \(captions.count) captions", category: .player) currentDownload = nil await play(video: queuedVideo.video, stream: stream, audioStream: nil, startTime: 0) // Set available captions and auto-select preferred if !captions.isEmpty { self.availableCaptions = captions if let preferred = settingsManager?.preferredSubtitlesLanguage, let match = captions.first(where: { $0.baseLanguageCode == preferred || $0.languageCode.hasPrefix(preferred) }) { loadCaption(match) } } return } catch { LoggingService.shared.error("[SubtitleDebug] Failed to resolve media source stream: \(error.localizedDescription)", category: .player) // Fall through to try other methods } } LoggingService.shared.debug("[SubtitleDebug] Using fallback path, queuedVideo has \(queuedVideo.captions.count) pre-loaded captions", category: .player) // Always check for downloads first - prefer local files over pre-loaded network streams var playedFromDownload = false let videoID = queuedVideo.video.id if let downloadManager { if let download = downloadManager.download(for: videoID) { if download.status == .completed { if let (downloadedVideo, localStream, audioStream, captionURL, dislikeCount) = downloadManager.videoAndStream(for: download) { currentDownload = download playedFromDownload = true LoggingService.shared.debug("[Player] Playing queued video \(videoID.videoID) from local download", category: .player) await play(video: downloadedVideo, stream: localStream, audioStream: audioStream, startTime: 0) if let dislikeCount { state.dislikeCount = dislikeCount } if let captionURL { loadLocalCaption(url: captionURL) } } else { LoggingService.shared.warning("[Player] Download for \(videoID.videoID) is completed but videoAndStream returned nil (local file missing?)", category: .player) toastManager?.show( category: .download, title: String(localized: "toast.download.fileMissing.title"), icon: "exclamationmark.triangle", iconColor: .orange, autoDismissDelay: 4.0 ) } } else { LoggingService.shared.warning("[Player] Download found for \(videoID.videoID) but status is \(download.status) (not completed)", category: .player) } } else { let completed = downloadManager.completedDownloads.count let active = downloadManager.activeDownloads.count LoggingService.shared.warning("[Player] No download record found for \(videoID.videoID) (completed: \(completed), active: \(active))", category: .player) } } else { LoggingService.shared.warning("[Player] downloadManager is nil when trying to play \(videoID.videoID)", category: .player) } if !playedFromDownload { currentDownload = nil await play(video: queuedVideo.video, stream: queuedVideo.stream, audioStream: queuedVideo.audioStream, startTime: 0) // Load captions if available if !queuedVideo.captions.isEmpty { self.availableCaptions = queuedVideo.captions // Auto-select preferred language if let preferred = settingsManager?.preferredSubtitlesLanguage, let match = queuedVideo.captions.first(where: { $0.baseLanguageCode == preferred || $0.languageCode.hasPrefix(preferred) }) { loadCaption(match) } } } } /// Plays a video, preferring local downloaded content if available. /// If the video has been downloaded, plays the local file instead of streaming. /// For media browser videos, resolves stream and captions on-demand. /// If not downloaded, uses the provided fallback streams or fetches from API. /// - Parameters: /// - video: The video to play /// - fallbackStream: Optional stream to use if video is not downloaded /// - fallbackAudioStream: Optional audio stream to use if video is not downloaded /// - startTime: Optional start time in seconds func playPreferringDownloaded( video: Video, fallbackStream: Stream? = nil, fallbackAudioStream: Stream? = nil, startTime: TimeInterval? = nil ) async { // Check if this is a media source video needing on-demand resolution // Uses unified method that fetches folder contents dynamically - works from any playback source if video.isFromMediaSource { do { let (stream, captions) = try await resolveMediaSourceStream(for: video) currentDownload = nil await play(video: video, stream: stream, audioStream: nil, startTime: startTime) // Set available captions and auto-select preferred if !captions.isEmpty { self.availableCaptions = captions if let preferred = settingsManager?.preferredSubtitlesLanguage, let match = captions.first(where: { $0.baseLanguageCode == preferred || $0.languageCode.hasPrefix(preferred) }) { loadCaption(match) } } return } catch { LoggingService.shared.error("Failed to resolve media source stream: \(error.localizedDescription)", category: .player) // Fall through to try other methods } } // Check if video is downloaded and play locally if so if let downloadManager, let download = downloadManager.download(for: video.id), download.status == .completed { if let (downloadedVideo, localStream, audioStream, captionURL, dislikeCount) = downloadManager.videoAndStream(for: download) { // Store the download info for later reference currentDownload = download await play(video: downloadedVideo, stream: localStream, audioStream: audioStream, startTime: startTime) // Restore dislike count from download (for offline playback) if let dislikeCount { state.dislikeCount = dislikeCount } // Load caption if available, otherwise ensure subtitles are disabled if let captionURL { loadLocalCaption(url: captionURL) } // Note: Storyboards are now loaded in play() for all downloaded videos // If downloaded video doesn't have full details (likeCount, viewCount), fetch them from API // This runs in the background so playback starts immediately from local file if downloadedVideo.supportsAPIStats, downloadedVideo.likeCount == nil || downloadedVideo.viewCount == nil { Task { await fetchAndUpdateVideoDetails(for: downloadedVideo) } } else { // Video has full details from download, notify observers NotificationCenter.default.post(name: .videoDetailsDidLoad, object: nil) } } else { LoggingService.shared.warning("[Player] Download for \(video.id) is completed but videoAndStream returned nil (local file missing?)", category: .player) toastManager?.show( category: .download, title: String(localized: "toast.download.fileMissing.title"), icon: "exclamationmark.triangle", iconColor: .orange, autoDismissDelay: 4.0 ) currentDownload = nil await play(video: video, stream: fallbackStream, audioStream: fallbackAudioStream, startTime: startTime) } } else { currentDownload = nil await play(video: video, stream: fallbackStream, audioStream: fallbackAudioStream, startTime: startTime) } } /// Checks if the given video is currently loaded in the player. /// - Parameter video: The video to check /// - Returns: `true` if the video is currently loaded (playing, paused, or buffering) func isCurrentlyPlaying(video: Video) -> Bool { guard let currentVideo = state.currentVideo else { return false } guard currentVideo.id == video.id else { return false } // Check if we have an active playback state (not idle or failed) switch state.playbackState { case .playing, .paused, .buffering, .ready, .loading: return true case .idle, .ended, .failed: return false } } /// Opens a video in the player, expanding the player sheet. /// If the video is already playing, just expands the player without reloading. /// Respects the autoplay setting - if disabled, prepares the video with a play button overlay. /// - Parameters: /// - video: The video to open /// - startTime: Optional start time in seconds (used for continue watching) func openVideo(_ video: Video, startTime: TimeInterval? = nil) { // Check if MPV PiP is active - if so, don't expand the player #if os(iOS) let mpvPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false #else let mpvPiPActive = false #endif // If this video is already playing, just expand the player (unless PiP is active) if isCurrentlyPlaying(video: video) { LoggingService.shared.logPlayer("Video already playing, just expanding") if !mpvPiPActive { navigationCoordinator?.expandPlayer() } return } // Cancel any previous play task before starting a new one currentPlayTask?.cancel() // Set video info before expanding so the sheet animates with content visible state.setPlaybackState(.loading) state.setCurrentVideo(video, stream: nil) // Expand player immediately so it opens while loading (unless PiP is active) if !mpvPiPActive { navigationCoordinator?.expandPlayer() } currentPlayTask = Task { await playPreferringDownloaded(video: video, startTime: startTime) } } /// Opens a video with a specific stream (e.g., for downloaded content or media sources). /// If the video is already playing, just expands the player without reloading. /// - Parameters: /// - video: The video to open /// - stream: The specific stream to use /// - audioStream: Optional separate audio stream (for video-only streams) /// - download: Optional download to load local storyboards and captions from /// - captions: Optional array of external captions (e.g., from WebDAV subtitle files) func openVideo(_ video: Video, stream: Stream, audioStream: Stream? = nil, download: Download? = nil, captions: [Caption] = []) { // Check if MPV PiP is active - if so, don't expand the player #if os(iOS) let mpvPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false #else let mpvPiPActive = false #endif // If this video is already playing, just expand the player (unless PiP is active) if isCurrentlyPlaying(video: video) { if !mpvPiPActive { navigationCoordinator?.expandPlayer() } return } // Cancel any previous play task before starting a new one currentPlayTask?.cancel() // Set video info before expanding so the sheet animates with content visible state.setPlaybackState(.loading) state.setCurrentVideo(video, stream: stream, audioStream: audioStream) // Expand player immediately so it opens while loading (unless PiP is active) if !mpvPiPActive { navigationCoordinator?.expandPlayer() } // Try to look up download if not provided but playing local file var resolvedDownload = download if resolvedDownload == nil, let downloadManager { resolvedDownload = downloadManager.download(for: video.id) } // Store download for reference currentDownload = resolvedDownload LoggingService.shared.logPlayer("openVideo with stream called - download: \(resolvedDownload != nil ? "present" : "nil")") currentPlayTask = Task { await play(video: video, stream: stream, audioStream: audioStream) // Note: Storyboards are now loaded in play() for all downloaded videos // Load local caption if download has caption if let resolvedDownload, let captionPath = resolvedDownload.localCaptionPath, let downloadManager { let captionURL = downloadManager.downloadsDirectory().appendingPathComponent(captionPath) if FileManager.default.fileExists(atPath: captionURL.path) { LoggingService.shared.logPlayer("Loading local caption: \(captionPath)") loadLocalCaption(url: captionURL) } } // Set external captions (e.g., from WebDAV subtitle files) if !captions.isEmpty { LoggingService.shared.logPlayer("Setting \(captions.count) external caption(s)") self.availableCaptions = captions // Auto-select based on preferred language setting if let preferred = settingsManager?.preferredSubtitlesLanguage, let match = captions.first(where: { $0.baseLanguageCode == preferred || $0.languageCode.hasPrefix(preferred) }) { LoggingService.shared.logPlayer("Auto-selecting caption: \(match.displayName)") loadCaption(match) } } } } /// Plays the previous video in queue. /// If more than 3 seconds into current video, restarts it instead. func playPrevious() async { // Pause without saving progress if video ended - 100% was already saved in backendDidFinishPlaying pause(saveProgress: state.playbackState != .ended) // If more than 3 seconds in, restart current video if state.currentTime > 3 { await seek(to: 0) resume() return } // Try to go back to previous video in history if let previous = state.retreatQueue() { // Push current video to front of queue (starts at 0 when replayed) if let currentVideo = state.currentVideo { state.insertNext(currentVideo, stream: state.currentStream, audioStream: state.currentAudioStream) } // Play the previous video, resuming from saved position await play(video: previous.video, stream: previous.stream, audioStream: previous.audioStream, startTime: previous.startTime) } else { // No history, just restart current video await seek(to: 0) resume() } } // MARK: - Stream Refresh /// Refreshes stream URLs and resumes playback at the specified time. /// Called when mid-playback failure is detected (e.g., expired stream URLs). private func refreshStreamsAndResume(atTime resumeTime: TimeInterval?) async { guard let video = state.currentVideo else { LoggingService.shared.logPlayerError("Cannot refresh streams: no current video") return } // Check if we've exceeded refresh attempts streamRefreshAttempts += 1 if streamRefreshAttempts > maxStreamRefreshAttempts { LoggingService.shared.logPlayerError("Stream refresh failed: exceeded max attempts (\(maxStreamRefreshAttempts))") toastManager?.show( category: .playerStatus, title: String(localized: "toast.player.streamRefreshFailed.title"), icon: "exclamationmark.triangle", iconColor: .red, autoDismissDelay: 3.0 ) // Report error to delegate let error = BackendError.loadFailed("Stream refresh failed after \(maxStreamRefreshAttempts) attempts") delegate?.playerService(self, didEncounterError: error) state.setPlaybackState(.failed(error)) return } LoggingService.shared.logPlayer("Refreshing streams (attempt \(streamRefreshAttempts)/\(maxStreamRefreshAttempts))") // Show toast that refresh is starting toastManager?.show( category: .playerStatus, title: String(localized: "toast.player.refreshingStream.title"), icon: "arrow.clockwise", iconColor: .blue, autoDismissDelay: 2.0 ) do { // Fetch fresh streams let (_, streams, captions) = try await fetchVideoStreamsAndCaptions(for: video) guard !streams.isEmpty else { throw APIError.noStreams } // Update available streams self.availableStreams = streams self.availableCaptions = captions // Find matching stream or best alternative let newStream = findMatchingStream(in: streams, preferring: state.currentStream) let newAudioStream: Stream? if newStream.isVideoOnly { // Find matching audio stream newAudioStream = findMatchingAudioStream(in: streams, preferring: state.currentAudioStream) } else { newAudioStream = nil } LoggingService.shared.logPlayer("Resuming with stream: \(newStream.qualityLabel) at \(resumeTime ?? 0)s") // Resume playback with fresh stream await play(video: video, stream: newStream, audioStream: newAudioStream, startTime: resumeTime) // Reset refresh counter on success streamRefreshAttempts = 0 // Show success toast toastManager?.show( category: .playerStatus, title: String(localized: "toast.player.streamRefreshed.title"), icon: "checkmark.circle", iconColor: .green, autoDismissDelay: 2.0 ) } catch { LoggingService.shared.logPlayerError("Stream refresh failed", error: error) // If still under max attempts, the next error will trigger another refresh // Otherwise, show error to user if streamRefreshAttempts >= maxStreamRefreshAttempts { toastManager?.show( category: .playerStatus, title: String(localized: "toast.player.streamRefreshFailed.title"), icon: "exclamationmark.triangle", iconColor: .red, autoDismissDelay: 3.0 ) delegate?.playerService(self, didEncounterError: error) state.setPlaybackState(.failed(error)) } } } /// Finds a stream matching the preferred stream's quality, or the best available alternative. private func findMatchingStream(in streams: [Stream], preferring preferred: Stream?) -> Stream { let videoStreams = streams.filter { !$0.isAudioOnly } // If we have a preferred stream, try to match its resolution if let preferred, let preferredResolution = preferred.resolution { // First try exact resolution match if let exact = videoStreams.first(where: { $0.resolution == preferredResolution }) { return exact } // Then try closest resolution let sorted = videoStreams.sorted { stream1, stream2 in let diff1 = abs((stream1.resolution?.height ?? 0) - preferredResolution.height) let diff2 = abs((stream2.resolution?.height ?? 0) - preferredResolution.height) return diff1 < diff2 } if let closest = sorted.first { return closest } } // Fall back to best available stream return videoStreams.max(by: { ($0.resolution?.height ?? 0) < ($1.resolution?.height ?? 0) }) ?? streams.first! } /// Finds an audio stream matching the preferred stream, or the best available alternative. private func findMatchingAudioStream(in streams: [Stream], preferring preferred: Stream?) -> Stream? { let audioStreams = streams.filter { $0.isAudioOnly } guard !audioStreams.isEmpty else { return nil } // If we have a preferred audio stream, try to match its language/codec if let preferred { // Try exact language match if let preferredLang = preferred.audioLanguage, let match = audioStreams.first(where: { $0.audioLanguage == preferredLang }) { return match } } // Fall back to best available (highest bitrate) return audioStreams.max(by: { ($0.bitrate ?? 0) < ($1.bitrate ?? 0) }) } // MARK: - Captions /// Loads and displays a caption track. /// Only works with MPV backend. /// - Parameter caption: The caption to load, or nil to disable subtitles func loadCaption(_ caption: Caption?) { guard let mpvBackend = currentBackend as? MPVBackend else { LoggingService.shared.debug("Cannot load caption: not using MPV backend", category: .player) return } mpvBackend.loadCaption(caption) currentCaption = caption if let caption { LoggingService.shared.logPlayer("Loaded caption: \(caption.displayName)") } else { LoggingService.shared.logPlayer("Disabled subtitles") } } /// Loads a local subtitle file from disk. /// Only works with MPV backend. /// - Parameter url: The local file URL of the caption file func loadLocalCaption(url: URL) { // Extract language from filename (e.g., "videoID_en.vtt" -> "en") let filename = url.deletingPathExtension().lastPathComponent let languageCode = filename.components(separatedBy: "_").last ?? "unknown" let languageName = Locale.current.localizedString(forLanguageCode: languageCode) ?? languageCode let caption = Caption( label: languageName, languageCode: languageCode, url: url ) loadCaption(caption) } /// Loads online streams for the current video (when playing downloaded content). /// After loading, the user can switch to an online stream from QualitySelectorView. /// The downloaded stream is preserved and mixed in with online streams. func loadOnlineStreams() async { guard let video = state.currentVideo else { return } // Keep track of current downloaded streams (local file URLs) let downloadedStreams = availableStreams.filter { $0.url.isFileURL } isLoadingOnlineStreams = true defer { isLoadingOnlineStreams = false } do { let (_, streams, captions) = try await fetchVideoStreamsAndCaptions(for: video) // Combine downloaded streams with online streams (downloaded first) availableStreams = downloadedStreams + streams availableCaptions = captions LoggingService.shared.logPlayer("Loaded \(streams.count) online streams and \(captions.count) captions (keeping \(downloadedStreams.count) downloaded)") } catch { LoggingService.shared.logPlayerError("Failed to load online streams", error: error) } } /// Switches from downloaded content to an online stream. /// - Parameters: /// - stream: The online stream to switch to /// - audioStream: Optional separate audio stream func switchToOnlineStream(_ stream: Stream, audioStream: Stream? = nil) async { guard let video = state.currentVideo else { return } // Clear the download flag since we're now playing online currentDownload = nil // Get current playback position to resume at let currentTime = state.currentTime // Play the new stream from the current position await play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) } // MARK: - Media Browser Stream Resolution /// Resolves stream and captions for a media browser video on-demand. /// - Parameters: /// - video: The video to resolve /// - context: Media browser context with source and folder files /// - Returns: Tuple of (stream, captions) ready for playback func resolveMediaBrowserStream( for video: Video, context: MediaBrowserQueueContext ) async throws -> (stream: Stream, captions: [Caption]) { // Find the MediaFile for this video in the folder // videoID format: "sourceUUID:path" guard let mediaFile = context.allFilesInFolder.first(where: { $0.id == video.id.videoID }) else { throw MediaSourceError.pathNotFound("Video file not found in folder context") } // Find matching subtitle files let subtitleFiles = mediaFile.findMatchingSubtitles(in: context.allFilesInFolder) let source = context.source let password = mediaSourcesManager?.password(for: source) switch source.type { case .webdav: // Get auth headers for WebDAV var authHeaders: [String: String]? if let webDAVClient { authHeaders = await webDAVClient.authHeaders(for: source, password: password) } let stream = mediaFile.toStream(authHeaders: authHeaders) // Subtitles can use remote URLs directly for WebDAV return (stream, subtitleFiles) case .smb: guard let smbClient else { throw MediaSourceError.unknown("SMB client not available") } let folderKey = source.id.uuidString + ":" + context.folderPath // Pre-download ALL subtitles in folder on first access. // Must complete before MPV opens any SMB connection. // For queued videos from the same folder, subtitles will already be cached. if !preDownloadedSubtitleFolders.contains(folderKey) { let allSubtitleFiles = context.allFilesInFolder.filter { $0.isSubtitle } LoggingService.shared.logMediaSources("Pre-downloading \(allSubtitleFiles.count) subtitle(s) from folder: \(context.folderPath)") for subtitleFile in allSubtitleFiles { do { let localURL = try await smbClient.downloadSubtitleToTemp( file: subtitleFile, source: source, password: password, videoID: folderKey ) downloadedSubtitlesCache[subtitleFile.id] = localURL LoggingService.shared.logMediaSources("Pre-downloaded subtitle: \(subtitleFile.name) → \(localURL.lastPathComponent)") } catch { LoggingService.shared.error( "Failed to pre-download subtitle \(subtitleFile.name): \(error.localizedDescription)", category: .general ) } } preDownloadedSubtitleFolders.insert(folderKey) // Release app SMB context before MPV opens its connection. // libsmbclient uses process-global talloc state that corrupts // when two contexts access the same server concurrently. await smbClient.clearCache(for: source) LoggingService.shared.logMediaSources("Cleared SMB context cache before playback") } // Construct video playback URL (pure string manipulation, no libsmbclient) let playbackURL = try await smbClient.constructPlaybackURL( for: mediaFile, source: source, password: password ) let stream = Stream(url: playbackURL, resolution: nil, format: mediaFile.fileExtension) // Build captions from pre-downloaded local files var localCaptions: [Caption] = [] for subtitle in subtitleFiles { if let subtitleFile = context.allFilesInFolder.first(where: { $0.url == subtitle.url }), let localURL = downloadedSubtitlesCache[subtitleFile.id] { localCaptions.append(Caption( label: subtitle.label, languageCode: subtitle.languageCode, url: localURL )) LoggingService.shared.info( "Using pre-downloaded subtitle: \(subtitleFile.name)", category: .general ) } } return (stream, localCaptions) case .localFolder: // Local folder - no auth needed, use file:// URLs directly let stream = mediaFile.toStream(authHeaders: nil) return (stream, subtitleFiles) } } /// Resolves stream and subtitles for a media source video by fetching folder contents on-demand. /// Works for all media source types (WebDAV, SMB, local folders) from any playback source /// (Media Browser, Continue Watching, etc.). func resolveMediaSourceStream(for video: Video) async throws -> (stream: Stream, captions: [Caption]) { LoggingService.shared.debug("[SubtitleDebug] resolveMediaSourceStream called for videoID: \(video.id.videoID)", category: .player) // 1. Extract source ID and file path from video ID guard let sourceID = video.mediaSourceID, let filePath = video.mediaSourceFilePath else { LoggingService.shared.error("[SubtitleDebug] Failed to extract source info - mediaSourceID: \(String(describing: video.mediaSourceID)), mediaSourceFilePath: \(String(describing: video.mediaSourceFilePath))", category: .player) throw MediaSourceError.pathNotFound("Could not extract source info from video ID") } LoggingService.shared.debug("[SubtitleDebug] Extracted sourceID: \(sourceID), filePath: \(filePath)", category: .player) // 2. Look up the MediaSource guard let source = mediaSourcesManager?.source(byID: sourceID) else { LoggingService.shared.error("[SubtitleDebug] Media source not found for ID: \(sourceID)", category: .player) throw MediaSourceError.unknown("Media source not found") } LoggingService.shared.debug("[SubtitleDebug] Found source: \(source.name), type: \(source.type)", category: .player) let password = mediaSourcesManager?.password(for: source) let parentPath = (filePath as NSString).deletingLastPathComponent let fileName = (filePath as NSString).lastPathComponent LoggingService.shared.debug("[SubtitleDebug] parentPath: \(parentPath), fileName: \(fileName)", category: .player) // 3. Fetch folder contents based on source type (with cache for queued playback) let cacheKey = "\(sourceID):\(parentPath)" let folderFiles: [MediaFile] if let cached = folderFilesCache[cacheKey] { LoggingService.shared.debug("[SubtitleDebug] Using cached folder listing for \(cacheKey) (\(cached.count) files)", category: .player) folderFiles = cached } else { switch source.type { case .webdav: guard let webDAVClient else { throw MediaSourceError.unknown("WebDAV client not available") } folderFiles = try await webDAVClient.listFiles(at: parentPath, source: source, password: password) case .smb: guard let smbClient else { throw MediaSourceError.unknown("SMB client not available") } folderFiles = try await smbClient.listFiles(at: parentPath, source: source, password: password) case .localFolder: guard let localFileClient else { throw MediaSourceError.unknown("Local file client not available") } folderFiles = try await localFileClient.listFiles(at: parentPath, source: source) } folderFilesCache[cacheKey] = folderFiles LoggingService.shared.debug("[SubtitleDebug] Listed \(folderFiles.count) files in folder, cached as \(cacheKey)", category: .player) } // 4. Find the MediaFile for this video guard let mediaFile = folderFiles.first(where: { $0.name == fileName }) else { throw MediaSourceError.pathNotFound("Video file not found in folder") } // 5. Find matching subtitles let subtitleCaptions = mediaFile.findMatchingSubtitles(in: folderFiles) // 6. Build stream and process captions based on source type switch source.type { case .webdav: // Get auth headers for WebDAV var authHeaders: [String: String]? if let webDAVClient { authHeaders = await webDAVClient.authHeaders(for: source, password: password) } let stream = mediaFile.toStream(authHeaders: authHeaders) // Subtitles can use remote URLs directly for WebDAV return (stream, subtitleCaptions) case .smb: guard let smbClient else { throw MediaSourceError.unknown("SMB client not available") } let folderKey = "\(sourceID):\(parentPath)" // Pre-download ALL subtitles in folder on first access. // Must complete before MPV opens any SMB connection. // For queued videos from the same folder, subtitles will already be cached. if !preDownloadedSubtitleFolders.contains(folderKey) { let allSubtitleFiles = folderFiles.filter { $0.isSubtitle } LoggingService.shared.logMediaSources("Pre-downloading \(allSubtitleFiles.count) subtitle(s) from folder: \(parentPath)") for subtitleFile in allSubtitleFiles { do { let localURL = try await smbClient.downloadSubtitleToTemp( file: subtitleFile, source: source, password: password, videoID: folderKey ) downloadedSubtitlesCache[subtitleFile.id] = localURL LoggingService.shared.logMediaSources("Pre-downloaded subtitle: \(subtitleFile.name) → \(localURL.lastPathComponent)") } catch { LoggingService.shared.error( "Failed to pre-download subtitle \(subtitleFile.name): \(error.localizedDescription)", category: .general ) } } preDownloadedSubtitleFolders.insert(folderKey) // Release app SMB context before MPV opens its connection. // libsmbclient uses process-global talloc state that corrupts // when two contexts access the same server concurrently. await smbClient.clearCache(for: source) LoggingService.shared.logMediaSources("Cleared SMB context cache before playback") } // Construct video playback URL (pure string manipulation, no libsmbclient) let playbackURL = try await smbClient.constructPlaybackURL( for: mediaFile, source: source, password: password ) let stream = Stream(url: playbackURL, resolution: nil, format: mediaFile.fileExtension) // Build captions from pre-downloaded local files var localCaptions: [Caption] = [] for subtitle in subtitleCaptions { if let subtitleFile = folderFiles.first(where: { $0.url == subtitle.url }), let localURL = downloadedSubtitlesCache[subtitleFile.id] { localCaptions.append(Caption( label: subtitle.label, languageCode: subtitle.languageCode, url: localURL )) LoggingService.shared.info( "Using pre-downloaded subtitle: \(subtitleFile.name)", category: .general ) } } return (stream, localCaptions) case .localFolder: // Local folder - no auth needed, use file:// URLs directly let stream = mediaFile.toStream(authHeaders: nil) return (stream, subtitleCaptions) } } // MARK: - Private Methods /// Checks if the stream URL goes through Yattee Server's fast download endpoint. /// Fast endpoint streams don't have Content-Length, so MPV can't determine accurate duration. /// In this case, we should use the API-provided duration instead. private func isFastEndpointStream(_ stream: Stream?) -> Bool { guard let urlString = stream?.url.absoluteString else { return false } return urlString.contains("/proxy/fast/") } /// Locks duration from API if playing through fast endpoint. /// This prevents progress bar jitter when MPV reports changing duration during progressive download. private func lockDurationIfNeeded(for video: Video, stream: Stream?) { if isFastEndpointStream(stream), video.duration > 0 { state.lockDuration(video.duration) LoggingService.shared.debug("Locked duration from API: \(video.duration)s (fast endpoint stream)", category: .player) } } private func setupAudioSession() { #if os(iOS) || os(tvOS) do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .moviePlayback) try session.setActive(true) // Only register observer once guard !hasRegisteredInterruptionObserver else { return } hasRegisteredInterruptionObserver = true LoggingService.shared.debug("Registering audio session interruption observer", category: .player) // Register for audio interruption notifications (phone calls, alarms, etc.) NotificationCenter.default.addObserver( forName: AVAudioSession.interruptionNotification, object: session, queue: .main ) { [weak self] notification in // Extract values before async boundary to satisfy Sendable requirements guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt Task { @MainActor [weak self] in LoggingService.shared.debug("Audio interruption notification received, type: \(type.rawValue), options: \(optionsValue ?? 0)", category: .player) self?.handleAudioSessionInterruption(type: type, optionsValue: optionsValue) } } } catch { LoggingService.shared.logPlayerError("Audio session setup failed", error: error) } #endif } #if os(iOS) || os(tvOS) private func handleAudioSessionInterruption(type: AVAudioSession.InterruptionType, optionsValue: UInt?) { LoggingService.shared.debug("handleAudioSessionInterruption called, current playbackState: \(state.playbackState), wasInterrupted: \(wasInterrupted)", category: .player) switch type { case .began: LoggingService.shared.debug("Audio session interrupted (began), playbackState: \(state.playbackState)", category: .player) // Audio was interrupted (phone call, alarm, etc.) if state.playbackState == .playing { LoggingService.shared.debug("Pausing playback due to interruption", category: .player) // Call full pause() to properly pause the backend pause() wasInterrupted = true } else { LoggingService.shared.debug("Not pausing - playbackState is not .playing", category: .player) } case .ended: LoggingService.shared.debug("Audio session interruption ended, wasInterrupted: \(wasInterrupted)", category: .player) // Interruption ended - check if we should resume if let optionsValue { let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) LoggingService.shared.debug("Interruption options: shouldResume=\(options.contains(.shouldResume))", category: .player) if options.contains(.shouldResume) && wasInterrupted { LoggingService.shared.debug("Auto-resuming playback after interruption", category: .player) // Reactivate audio session before resuming - iOS requires this after interruption do { try AVAudioSession.sharedInstance().setActive(true) LoggingService.shared.debug("Audio session reactivated successfully", category: .player) } catch { LoggingService.shared.error("Failed to reactivate audio session: \(error.localizedDescription)", category: .player) } resume() } else { LoggingService.shared.debug("Not auto-resuming: shouldResume=\(options.contains(.shouldResume)), wasInterrupted=\(wasInterrupted)", category: .player) } } else { LoggingService.shared.debug("No interruption options provided", category: .player) } wasInterrupted = false @unknown default: LoggingService.shared.debug("Unknown interruption type: \(type.rawValue)", category: .player) break } } #endif private func fetchVideoStreamsAndCaptions(for video: Video) async throws -> (Video, [Stream], [Caption]) { let result = try await fetchVideoStreamsAndCaptionsAndStoryboards(for: video) return (result.0, result.1, result.2) } private func fetchVideoStreamsAndCaptionsAndStoryboards(for video: Video) async throws -> (Video, [Stream], [Caption], [Storyboard]) { // Handle media source videos (WebDAV, SMB, and local folders) // These don't need API calls - we reconstruct the stream from the stored URL if case .extracted(let extractor, let originalURL) = video.id.source { if extractor == MediaFile.webdavProvider || extractor == MediaFile.localFolderProvider || extractor == MediaFile.smbProvider { let stream = try await createStreamForMediaSource(video: video, url: originalURL, extractor: extractor) return (video, [stream], [], []) } } guard let instance = try await findInstance(for: video) else { throw APIError.noInstance } // For extracted videos, use the extract endpoint with the original URL // (stream URLs expire, so we need to re-extract each time) if case .extracted(_, let originalURL) = video.id.source { guard instance.type == .yatteeServer else { throw APIError.notSupported } let result = try await contentService.extractURL(originalURL, instance: instance) return (result.video, result.streams, result.captions, []) } // Fetch full video details, streams, captions, and storyboards in a single API call // (for Invidious, this is a single request; for other backends, calls are made in parallel) let result = try await contentService.videoWithStreamsAndCaptionsAndStoryboards(id: video.id.videoID, instance: instance) return (result.video, result.streams, result.captions, result.storyboards) } /// Creates a stream for media source videos (WebDAV, SMB, or local folder). /// For WebDAV, adds authentication headers. For SMB, constructs URL with embedded credentials. private func createStreamForMediaSource(video: Video, url: URL, extractor: String) async throws -> Stream { // Parse the MediaSource UUID from the video ID // Format: "UUID:/path/to/file.mp4" let videoID = video.id.videoID var headers: [String: String]? = nil var playbackURL = url if let separatorIndex = videoID.firstIndex(of: ":"), let sourceUUID = UUID(uuidString: String(videoID[.. (stream: Stream?, audioStream: Stream?, backend: PlayerBackendType) { // Try preferred backend first if let (stream, audioStream) = selectStreams(for: preferredBackendType, from: streams) { return (stream, audioStream, preferredBackendType) } // Fallback to any available backend for backendType in backendFactory.availableBackends { if let (stream, audioStream) = selectStreams(for: backendType, from: streams) { return (stream, audioStream, backendType) } } return (nil, nil, preferredBackendType) } /// Selects best streams for preloading (exposed for QueueManager). func selectStreamsForPreload(from streams: [Stream]) -> (stream: Stream?, audioStream: Stream?) { let result = selectStreamAndBackend(from: streams) return (result.stream, result.audioStream) } private func selectStreams(for backendType: PlayerBackendType, from streams: [Stream]) -> (video: Stream, audio: Stream?)? { let supportedFormats = backendType.supportedFormats let dashEnabled = settingsManager?.dashEnabled ?? false // Get user's original quality preference (before network adjustments) let userPreferredQuality = settingsManager?.preferredQuality ?? .auto // Network-aware quality selection for fixed-bitrate streams let effectiveQuality: VideoQuality if let monitor = connectivityMonitor { if monitor.isConstrained { // Low Data Mode - be very conservative effectiveQuality = .sd480p } else if monitor.isCellular || monitor.isExpensive { // Cellular/expensive - use cellular quality setting effectiveQuality = settingsManager?.cellularQuality ?? .hd720p } else { effectiveQuality = userPreferredQuality } } else { effectiveQuality = userPreferredQuality } // Separate streams by type let videoOnlyStreams = streams.filter { stream in guard !stream.isAudioOnly && stream.isVideoOnly else { return false } let format = StreamFormat.detect(from: stream) return supportedFormats.contains(format) } let muxedStreams = streams.filter { stream in let format = StreamFormat.detect(from: stream) guard supportedFormats.contains(format) else { return false } if format == .dash && !dashEnabled { return false } // Only include HLS/DASH if they have video (audio-only HLS/DASH should be treated as audio streams) if format == .hls || format == .dash { return !stream.isAudioOnly } return stream.isMuxed } let audioStreams = streams.filter { $0.isAudioOnly } // Check for audio-only content (no real video streams available) // This handles cases like SoundCloud where HLS is audio-only but not marked as such let hasRealVideoStreams = !videoOnlyStreams.isEmpty || muxedStreams.contains { stream in // Real video muxed streams have resolution info // HLS/DASH without resolution could be audio-only (can't tell without parsing) let format = StreamFormat.detect(from: stream) if format == .hls || format == .dash { return stream.resolution != nil } return true } if !hasRealVideoStreams && !audioStreams.isEmpty { LoggingService.shared.debug("Stream selection: Audio-only content detected, using best audio stream", category: .player) // Select best audio stream by bitrate let bestAudio = audioStreams.sorted { ($0.bitrate ?? 0) > ($1.bitrate ?? 0) }.first! return (bestAudio, nil) } // Get the maximum resolution based on effective quality preference let maxResolution = effectiveQuality.maxResolution // For live streams, always prefer HLS/DASH (designed for live streaming) // Live streams with direct MP4 URLs (live=1&hang=1) don't work reliably let isLiveStream = streams.contains(where: { $0.isLive }) if isLiveStream { if let hlsStream = muxedStreams.first(where: { StreamFormat.detect(from: $0) == .hls }) { LoggingService.shared.debug("Stream selection: Using HLS for live stream", category: .player) return (hlsStream, nil) } if dashEnabled, let dashStream = muxedStreams.first(where: { StreamFormat.detect(from: $0) == .dash }) { LoggingService.shared.debug("Stream selection: Using DASH for live stream", category: .player) return (dashStream, nil) } LoggingService.shared.warning("Stream selection: No HLS/DASH available for live stream, falling back to adaptive formats", category: .player) } // Note: For non-live videos, we prefer progressive formats (MP4/WebM) over HLS/DASH // because they typically offer better quality. HLS/DASH are only used as last resort. // Live streams are already handled above with HLS/DASH preference. // Try to find the best video-only stream + audio (for MPV which supports all formats) if backendType == .mpv && !videoOnlyStreams.isEmpty && !audioStreams.isEmpty { let filteredVideoStreams: [Stream] if let maxRes = maxResolution { filteredVideoStreams = videoOnlyStreams.filter { stream in guard let resolution = stream.resolution else { return true } return resolution <= maxRes } } else { filteredVideoStreams = videoOnlyStreams } // Filter out codecs with priority 0 (software decode) if hardware options exist let hardwareDecodableStreams = filteredVideoStreams.filter { videoCodecPriority($0.videoCodec) > 0 } let streamsToConsider = hardwareDecodableStreams.isEmpty ? filteredVideoStreams : hardwareDecodableStreams // Sort by resolution first, then by codec priority let sortedVideo = streamsToConsider.sorted { s1, s2 in let res1 = s1.resolution ?? .p360 let res2 = s2.resolution ?? .p360 if res1 != res2 { return res1 > res2 } // Same resolution - prefer better codec return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec) } if let bestVideo = sortedVideo.first { // Select best audio stream based on preferred language, codec, and bitrate let preferredAudioLanguage = settingsManager?.preferredAudioLanguage let bestAudio = audioStreams .sorted { stream1, stream2 in // First priority: preferred language or original audio if let preferred = preferredAudioLanguage { // User selected a specific language let lang1 = stream1.audioLanguage ?? "" let lang2 = stream2.audioLanguage ?? "" let matches1 = lang1.hasPrefix(preferred) let matches2 = lang2.hasPrefix(preferred) if matches1 != matches2 { return matches1 } } else { // No preference set - prefer original audio track if stream1.isOriginalAudio != stream2.isOriginalAudio { return stream1.isOriginalAudio } } // Second priority: prefer Opus > AAC for MPV (better quality/compression) let codecPriority1 = audioCodecPriority(stream1.audioCodec) let codecPriority2 = audioCodecPriority(stream2.audioCodec) if codecPriority1 != codecPriority2 { return codecPriority1 > codecPriority2 } // Third priority: higher bitrate return (stream1.bitrate ?? 0) > (stream2.bitrate ?? 0) } .first if let audio = bestAudio { return (bestVideo, audio) } } } // Fallback to muxed streams - prefer progressive formats over HLS/DASH for non-live content let filteredMuxed: [Stream] if let maxRes = maxResolution { filteredMuxed = muxedStreams.filter { stream in guard let resolution = stream.resolution else { return true } return resolution <= maxRes } } else { filteredMuxed = muxedStreams } // Sort: prefer non-HLS/DASH (progressive) formats, then by resolution let sortedMuxed = filteredMuxed.sorted { s1, s2 in let format1 = StreamFormat.detect(from: s1) let format2 = StreamFormat.detect(from: s2) let isAdaptive1 = format1 == .hls || format1 == .dash let isAdaptive2 = format2 == .hls || format2 == .dash // Prefer progressive formats for non-live content if isAdaptive1 != isAdaptive2 { return !isAdaptive1 // non-adaptive (false) comes first } return (s1.resolution ?? .p360) > (s2.resolution ?? .p360) } if let bestMuxed = sortedMuxed.first { return (bestMuxed, nil) } // Last resort: any muxed stream (HLS/DASH will be selected here if nothing else available) if let anyMuxed = muxedStreams.sorted(by: { ($0.resolution ?? .p360) > ($1.resolution ?? .p360) }).first { return (anyMuxed, nil) } return nil } /// Returns codec priority for video streams (higher = better). /// Prefers hardware-decodable codecs for battery efficiency. private func videoCodecPriority(_ codec: String?) -> Int { HardwareCapabilities.shared.codecPriority(for: codec) } /// Returns codec priority for audio streams. /// Opus and AAC are treated equally - let bitrate decide quality. private func audioCodecPriority(_ codec: String?) -> Int { guard let codec = codec?.lowercased() else { return 0 } if codec.contains("opus") || codec.contains("aac") || codec.contains("mp4a") { return 1 // Both are good - bitrate will decide } return 0 } private func ensureBackend(type: PlayerBackendType) async throws -> any PlayerBackend { // Reuse existing backend if same type if let current = currentBackend, current.backendType == type { return current } // Create new backend let backend = try backendFactory.createBackend(type: type) backend.delegate = self currentBackend = backend // Configure MPV-specific settings #if os(iOS) || os(macOS) if let mpvBackend = backend as? MPVBackend { // Configure PiP callbacks if let coordinator = navigationCoordinator { mpvBackend.onRestoreFromPiP = { [weak coordinator] in // If mini player video is disabled, expand player for restore // Otherwise video continues in mini player if MiniPlayerSettings.cached.showVideo == false { coordinator?.expandPlayer() } } mpvBackend.onPiPDidStart = { [weak coordinator] in guard let coordinator else { LoggingService.shared.debug("PlayerService: onPiPDidStart - coordinator is nil", category: .player) return } // Collapse the player sheet/window when PiP starts LoggingService.shared.debug("PlayerService: onPiPDidStart - isPlayerExpanded=\(coordinator.isPlayerExpanded)", category: .player) if coordinator.isPlayerExpanded { // Set collapsing first so mini player shows video immediately coordinator.isPlayerCollapsing = true coordinator.isPlayerExpanded = false LoggingService.shared.debug("PlayerService: onPiPDidStart - set isPlayerExpanded to false", category: .player) } } #if os(macOS) // Clean up hidden window when PiP is closed via X button (not restore) mpvBackend.onPiPDidStopWithoutRestore = { LoggingService.shared.debug("PlayerService: onPiPDidStopWithoutRestore - cleaning up hidden window", category: .player) ExpandedPlayerWindowManager.shared.cleanupAfterPiP() } #endif } } #endif return backend } private var instancesManager: InstancesManager? private weak var mediaSourcesManager: MediaSourcesManager? private var smbClient: SMBClient? private var webDAVClient: WebDAVClient? private var localFileClient: LocalFileClient? private var folderFilesCache: [String: [MediaFile]] = [:] /// Cache of pre-downloaded subtitle local file URLs. /// Key: subtitle MediaFile.id, Value: local temp URL private var downloadedSubtitlesCache: [String: URL] = [:] /// Tracks folders whose subtitles have been pre-downloaded. /// Prevents re-downloading when resolving queued videos from the same folder. private var preDownloadedSubtitleFolders: Set = [] /// Sets the instances manager for finding instances. func setInstancesManager(_ manager: InstancesManager) { self.instancesManager = manager } /// Sets the media sources manager for WebDAV/SMB/local folder playback. func setMediaSourcesManager(_ manager: MediaSourcesManager) { self.mediaSourcesManager = manager } /// Sets the SMB client for SMB playback. func setSMBClient(_ client: SMBClient) { self.smbClient = client } /// Sets the WebDAV client for WebDAV playback. func setWebDAVClient(_ client: WebDAVClient) { self.webDAVClient = client } /// Sets the local file client for local folder playback. func setLocalFileClient(_ client: LocalFileClient) { self.localFileClient = client } /// Sets the settings manager for accessing user preferences. func setSettingsManager(_ manager: SettingsManager) { self.settingsManager = manager self.backendSwitcher.settingsManager = manager self.nowPlayingService.settingsManager = manager // Reconfigure remote commands now that we have settings nowPlayingService.configureRemoteCommands() // Note: preferredBackendType is now computed directly from settingsManager } /// Reconfigures system control buttons (Control Center, Lock Screen) based on current settings. /// Call this when system controls settings change. func reconfigureSystemControls( mode: SystemControlsMode? = nil, duration: SystemControlsSeekDuration? = nil ) { nowPlayingService.configureRemoteCommands(mode: mode, duration: duration) } /// Observer for preset changes to reconfigure system controls. private var presetChangeObserver: NSObjectProtocol? /// Sets the player controls layout service for reading preset-specific settings. func setPlayerControlsLayoutService(_ service: PlayerControlsLayoutService) { self.playerControlsLayoutService = service nowPlayingService.playerControlsLayoutService = service // Reconfigure with the actual saved settings now that layout service is available nowPlayingService.configureRemoteCommands() // Observe preset changes to reconfigure system controls presetChangeObserver = NotificationCenter.default.addObserver( forName: .playerControlsActivePresetDidChange, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } Task { @MainActor [weak self] in self?.reconfigureSystemControls() } } } /// Sets the download manager for checking downloaded videos. func setDownloadManager(_ manager: DownloadManager) { self.downloadManager = manager } /// Sets the DeArrow branding provider for enhanced titles. func setDeArrowBrandingProvider(_ provider: DeArrowBrandingProvider) { nowPlayingService.deArrowBrandingProvider = provider } /// Sets the connectivity monitor for network-aware quality selection. func setConnectivityMonitor(_ monitor: ConnectivityMonitor) { self.connectivityMonitor = monitor } /// Sets the toast manager for displaying notifications. func setToastManager(_ manager: ToastManager) { self.toastManager = manager } /// Sets the queue manager for queue operations. func setQueueManager(_ manager: QueueManager) { self.queueManager = manager } /// Sets the navigation coordinator for waiting on sheet animations. func setNavigationCoordinator(_ coordinator: NavigationCoordinator) { self.navigationCoordinator = coordinator #if os(iOS) // Configure PiP restore callback - don't expand player, just let PiP close // Video continues in mini player; user taps mini player to expand if let mpvBackend = currentBackend as? MPVBackend { mpvBackend.onRestoreFromPiP = { [weak coordinator] in // If mini player video is disabled, expand player for restore // Otherwise video continues in mini player if MiniPlayerSettings.cached.showVideo == false { coordinator?.expandPlayer() } } } #endif } /// Sets the handoff manager for activity updates. func setHandoffManager(_ manager: HandoffManager) { self.handoffManager = manager } #if os(iOS) /// Called when the player sheet appears. func playerSheetDidAppear() { let backendType = currentBackend?.backendType.rawValue ?? "none" let playbackState = state.playbackState LoggingService.shared.debug("PlayerService: playerSheetDidAppear - backend=\(backendType), playbackState=\(playbackState)", category: .player) // Re-enable visual tracks and reattach layer when sheet appears let backgroundEnabled = settingsManager?.backgroundPlaybackEnabled ?? true let isPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false LoggingService.shared.debug("PlayerService: sheetDidAppear checks - backgroundEnabled=\(backgroundEnabled), isPiPActive=\(isPiPActive)", category: .player) guard backgroundEnabled && !isPiPActive else { LoggingService.shared.debug("PlayerService: skipping visibility handling (backgroundEnabled=\(backgroundEnabled), isPiPActive=\(isPiPActive))", category: .player) return } if let mpvBackend = currentBackend as? MPVBackend { LoggingService.shared.debug("Player sheet appeared - re-enabling video (MPV)", category: .player) mpvBackend.handlePlayerSheetVisibility(isVisible: true) } } /// Called when the player sheet disappears. func playerSheetDidDisappear() { let backendType = currentBackend?.backendType.rawValue ?? "none" let playbackState = state.playbackState LoggingService.shared.debug("PlayerService: playerSheetDidDisappear - backend=\(backendType), playbackState=\(playbackState)", category: .player) // Disable visual tracks and detach layer for background audio playback let backgroundEnabled = settingsManager?.backgroundPlaybackEnabled ?? true let mpvPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false LoggingService.shared.debug("PlayerService: sheetDidDisappear checks - backgroundEnabled=\(backgroundEnabled), mpvPiPActive=\(mpvPiPActive)", category: .player) guard backgroundEnabled && !mpvPiPActive else { LoggingService.shared.debug("PlayerService: skipping visibility handling on disappear (backgroundEnabled=\(backgroundEnabled), mpvPiP=\(mpvPiPActive))", category: .player) return } // Check if mini player video should be visible // If so, don't pause rendering - mini player will manage rendering state let miniPlayerVideoEnabled = MiniPlayerSettings.cached.showVideo let isAudioOnly = state.currentStream?.isAudioOnly == true let shouldMiniPlayerShowVideo = miniPlayerVideoEnabled && !isAudioOnly if shouldMiniPlayerShowVideo { LoggingService.shared.debug("PlayerService: skipping pause - mini player will show video (miniPlayerVideoEnabled=\(miniPlayerVideoEnabled), isAudioOnly=\(isAudioOnly))", category: .player) return } if let mpvBackend = currentBackend as? MPVBackend { LoggingService.shared.debug("Player sheet dismissed - disabling video for background playback (MPV)", category: .player) mpvBackend.handlePlayerSheetVisibility(isVisible: false) } } #endif private func findInstance(for video: Video) async throws -> Instance? { guard let instancesManager else { return nil } return instancesManager.instance(for: video) } private func fetchSponsorBlockSegments(for videoID: String) async { // Update SponsorBlock API URL from settings if let urlString = settingsManager?.sponsorBlockAPIURL, let url = URL(string: urlString) { await sponsorBlockAPI.setBaseURL(url) } do { let segments = try await sponsorBlockAPI.segments(for: videoID) state.sponsorSegments = segments } catch { LoggingService.shared.logPlayerError("SponsorBlock fetch failed", error: error) } } private func fetchReturnYouTubeDislikeCounts(for videoID: String) async { guard settingsManager?.returnYouTubeDislikeEnabled == true else { return } do { let votes = try await returnYouTubeDislikeAPI.votes(for: videoID) state.dislikeCount = votes.dislikes // Notify observers that video details updated NotificationCenter.default.post(name: .videoDetailsDidLoad, object: nil) } catch { LoggingService.shared.logPlayerError("Return YouTube Dislike fetch failed", error: error) } } /// Fetches full video details from API and updates state. /// Used for downloaded videos that may not have full metadata stored. private func fetchAndUpdateVideoDetails(for video: Video) async { guard let instance = try? await findInstance(for: video) else { return } do { let fullVideo = try await contentService.video(id: video.id.videoID, instance: instance) // Only update if this is still the current video guard state.currentVideo?.id == video.id else { return } // Update state with full video (preserving current stream) state.setCurrentVideo(fullVideo, stream: state.currentStream, audioStream: state.currentAudioStream) NotificationCenter.default.post(name: .videoDetailsDidLoad, object: nil) // Also fetch dislike count if enabled if case .global = video.id.source { await fetchReturnYouTubeDislikeCounts(for: video.id.videoID) } } catch { // Silently fail - offline playback continues with stored metadata } } private func startProgressSaveTimer() { progressSaveTimer?.invalidate() progressSaveTimer = Timer.scheduledTimer(withTimeInterval: progressSaveInterval, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.saveProgress() } } } private func saveProgress() { guard settingsManager?.incognitoModeEnabled != true, settingsManager?.saveWatchHistory != false else { return } guard let video = state.currentVideo, state.currentTime > 0 else { return } // Save locally only during playback - no iCloud sync overhead dataManager.updateWatchProgressLocal(for: video, seconds: state.currentTime, duration: state.duration) // Update Handoff activity with current playback time handoffManager?.updatePlaybackTime(state.currentTime) } /// Saves progress as 100% completed when video finishes naturally. /// This ensures the watch history shows full completion instead of ~99%. private func saveProgressAsCompleted() { guard settingsManager?.incognitoModeEnabled != true, settingsManager?.saveWatchHistory != false, let video = state.currentVideo else { return } // Use video.duration (API-reported) to match WatchEntry.duration stored value. // This ensures 100% progress since WatchEntry.progress = watchedSeconds / WatchEntry.duration. // Fall back to state.duration (MPV-reported) if video.duration is not available. let completedDuration = video.duration > 0 ? video.duration : state.duration guard completedDuration > 0 else { return } // Save the full duration as watched time when video completes dataManager.updateWatchProgressLocal(for: video, seconds: completedDuration, duration: completedDuration) // Update Handoff activity with completed time handoffManager?.updatePlaybackTime(completedDuration) } /// Saves progress and triggers iCloud sync for watch history. /// Call this when video playback ends or switches to a different video. private func saveProgressAndSync() { guard settingsManager?.incognitoModeEnabled != true, settingsManager?.saveWatchHistory != false else { return } guard let video = state.currentVideo, state.currentTime > 0 else { return } // Save and queue for iCloud sync (used when video closes/switches) dataManager.updateWatchProgress(for: video, seconds: state.currentTime, duration: state.duration) NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil) } private func checkSponsorBlockSegments(at time: Double) { // Read settings - use defaults if settings manager not available let enabled = settingsManager?.sponsorBlockEnabled ?? true let enabledCategories = settingsManager?.sponsorBlockCategories ?? SponsorBlockCategory.defaultEnabled guard enabled else { return } // Find segment at current time that matches enabled categories let applicableSegments = state.sponsorSegments.skippable().inCategories(enabledCategories) if let segment = applicableSegments.segment(at: time) { // Auto-skip all enabled categories // Prevent duplicate skips for the same segment guard lastSkippedSegmentID != segment.id else { return } if delegate?.playerService(self, shouldSkipSegment: segment) ?? true { lastSkippedSegmentID = segment.id Task { await skipSegment(segment) } } } else { state.currentSegment = nil // Clear last skipped when not in any segment (allows re-skip if user seeks back) lastSkippedSegmentID = nil } } /// Skips a specific segment by seeking past it. private func skipSegment(_ segment: SponsorBlockSegment) async { let skipTarget = segment.endTime + 0.1 // Don't skip if target is past video duration - this would cause an infinite loop // since we'd seek to end, still be "in" the segment, and skip again guard state.duration > 0, skipTarget < state.duration else { LoggingService.shared.logPlayer("SponsorBlock: not skipping \(segment.category.rawValue) segment (extends past video end)", details: "\(segment.startTime)s - \(segment.endTime)s, duration: \(state.duration)s") return } LoggingService.shared.logPlayer("SponsorBlock: skipping \(segment.category.rawValue) segment", details: "\(segment.startTime)s - \(segment.endTime)s") // Show loading for early skips (intro, early sponsors) to avoid brief video flash // But only if we haven't already shown video (still in loading state) let isEarlySkip = segment.startTime < 30 let isStillLoading = state.playbackState == .loading await seek(to: skipTarget, showLoading: isEarlySkip && isStillLoading) } private func cleanup() { // Clean up temp subtitle files for current video if let currentVideo = state.currentVideo { cleanupTempSubtitles(for: currentVideo.id.id) } progressSaveTimer?.invalidate() progressSaveTimer = nil // Remove audio session interruption observer #if os(iOS) || os(tvOS) NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) hasRegisteredInterruptionObserver = false #endif // Cancel any ongoing play task currentPlayTask?.cancel() currentPlayTask = nil currentBackend?.stop() // Keep backend alive for reuse - prevents race condition where old backend's // delayed deinit destroys render context that new backend is using // currentBackend = nil } /// Cleans up temporary subtitle files for a given video. /// Call this when closing/stopping a video. /// - Parameter videoID: The video ID whose subtitles to clean up. private func cleanupTempSubtitles(for videoID: String) { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("yattee-subtitles", isDirectory: true) .appendingPathComponent(videoID, isDirectory: true) do { if FileManager.default.fileExists(atPath: tempDir.path) { try FileManager.default.removeItem(at: tempDir) LoggingService.shared.debug("Cleaned up temp subtitles for: \(videoID)", category: .player) } } catch { LoggingService.shared.error( "Failed to clean up temp subtitles for \(videoID): \(error.localizedDescription)", category: .player ) } } /// Cleans up all temporary subtitle files (pre-downloaded and per-video). /// Called when stopping playback entirely. private func cleanupAllTempSubtitles() { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("yattee-subtitles", isDirectory: true) do { if FileManager.default.fileExists(atPath: tempDir.path) { try FileManager.default.removeItem(at: tempDir) LoggingService.shared.debug("Cleaned up all temp subtitles", category: .player) } } catch { LoggingService.shared.error( "Failed to clean up all temp subtitles: \(error.localizedDescription)", category: .player ) } } // MARK: - Chapter Resolution /// Resolves chapters for the video using source hierarchy. /// Priority: SponsorBlock chapters > description parsing /// - Parameter video: The video to resolve chapters for. private func resolveChapters(for video: Video) { let videoDuration = video.duration > 0 ? video.duration : state.duration guard videoDuration > 0 else { state.chapters = [] return } // 1. Try SponsorBlock chapters (if enabled and segments available) let sponsorBlockEnabled = settingsManager?.sponsorBlockEnabled ?? true if sponsorBlockEnabled { let chapters = state.sponsorSegments.extractChapters(videoDuration: videoDuration) if chapters.count >= 2 { state.chapters = chapters LoggingService.shared.logPlayer("Chapters: \(chapters.count) from SponsorBlock") return } } // 2. Fall back to description parsing if let description = video.description { let introTitle = String(localized: "player.chapters.intro") let chapters = ChapterParser.parse( description: description, videoDuration: videoDuration, introTitle: introTitle ) state.chapters = chapters if !chapters.isEmpty { LoggingService.shared.logPlayer("Chapters: \(chapters.count) from description") } } else { state.chapters = [] } } } // MARK: - PlayerBackendDelegate extension PlayerService: PlayerBackendDelegate { func backend(_ backend: any PlayerBackend, didUpdateTime time: TimeInterval) { // Ignore time updates while loading a new video - these are stale updates from the previous video guard loadingVideoID == nil else { return } state.currentTime = time delegate?.playerService(self, didUpdateTime: time) checkSponsorBlockSegments(at: time) // Update Now Playing time periodically (every ~5 seconds to avoid excessive updates) let shouldUpdateNowPlaying = Int(time) % 5 == 0 if shouldUpdateNowPlaying { nowPlayingService.updatePlaybackTime( currentTime: time, duration: state.duration, isPlaying: state.playbackState == .playing ) } } func backend(_ backend: any PlayerBackend, didUpdateDuration duration: TimeInterval) { // Skip duration updates when locked from API (fast endpoint streams where // file is progressively downloaded and MPV can't determine accurate duration) guard !state.isDurationLockedFromAPI else { return } state.duration = duration } func backend(_ backend: any PlayerBackend, didChangeState playbackState: PlaybackState) { LoggingService.shared.debug("Backend state changed to: \(playbackState)", category: .player) state.setPlaybackState(playbackState) delegate?.playerService(self, didChangeState: playbackState) } func backend(_ backend: any PlayerBackend, didUpdateBufferedTime time: TimeInterval) { state.bufferedTime = time } func backend(_ backend: any PlayerBackend, didUpdateBufferProgress progress: Int) { // Only update during initial buffering (before buffer is ready) // Skip buffer progress for local files - they load quickly and don't need buffering indicator let isLocalFile = state.currentStream?.url.isFileURL == true if !state.isBufferReady && !isLocalFile { state.bufferProgress = progress } } func backend(_ backend: any PlayerBackend, didEncounterError error: Error) { delegate?.playerService(self, didEncounterError: error) } func backend(_ backend: any PlayerBackend, didUpdateVideoSize width: Int, height: Int) { guard width > 0, height > 0 else { return } let aspectRatio = Double(width) / Double(height) state.videoAspectRatio = aspectRatio LoggingService.shared.debug("Video aspect ratio updated: \(aspectRatio) (\(width)x\(height))", category: .player) } func backend(_ backend: any PlayerBackend, didUpdateRetryState currentRetry: Int, maxRetries: Int, isRetrying: Bool, exhausted: Bool) { let retryState = RetryState( currentRetry: currentRetry, maxRetries: maxRetries, isRetrying: isRetrying, exhausted: exhausted ) state.setRetryState(retryState) } func backend(_ backend: any PlayerBackend, didRequestStreamRefresh atTime: TimeInterval?) { LoggingService.shared.logPlayer("Stream refresh requested at time: \(atTime ?? -1)") Task { await refreshStreamsAndResume(atTime: atTime) } } func backendDidBecomeReady(_ backend: any PlayerBackend) { // First frame of new video has been rendered - thumbnail can now hide state.isFirstFrameReady = true } func backendDidFinishPlaying(_ backend: any PlayerBackend) { // Mark that video ended naturally - prevents play() from saving progress again // when switching to next video (100% is already saved below) videoEndedNaturally = true // Stop the progress save timer BEFORE saving completion // Otherwise the timer can overwrite our 100% with a lower value progressSaveTimer?.invalidate() progressSaveTimer = nil state.setPlaybackState(.ended) saveProgressAsCompleted() delegate?.playerServiceDidFinishPlaying(self) // Auto-play immediately if player is not visible (no point showing countdown) // UI (ExpandedPlayerSheet) will handle countdown when player is visible let autoPlayEnabled = settingsManager?.queueAutoPlayNext ?? true let hasNextInQueue = !state.queue.isEmpty if autoPlayEnabled && hasNextInQueue { let isSheetCollapsed = navigationCoordinator?.isPlayerExpanded != true let isInBackground = currentScenePhase == .background let isPiPActive = state.pipState == .active if isSheetCollapsed || isInBackground || isPiPActive { Task { await playNext() } } else { // UI will handle countdown - allow sleep while waiting // (preventSleep will be called again when next video starts) sleepPreventionService.allowSleep() } } else { // No auto-play will happen - allow sleep sleepPreventionService.allowSleep() } } /// Called when a video starts playing to trigger proactive continuation loading. func notifyVideoStarted() { queueManager?.onVideoStarted() } /// Handles playback errors by auto-skipping to next video if available and player is not visible. /// When player is visible, user can manually tap "Play Next" button on the error overlay. private func handlePlaybackErrorAutoSkip() async { let autoPlayEnabled = settingsManager?.queueAutoPlayNext ?? true let hasNextInQueue = !state.queue.isEmpty guard autoPlayEnabled && hasNextInQueue else { return } let isSheetCollapsed = navigationCoordinator?.isPlayerExpanded != true let isInBackground = currentScenePhase == .background let isPiPActive = state.pipState == .active // Skip immediately if player is NOT visible to user (no point showing error screen) if isSheetCollapsed || isInBackground || isPiPActive { // Show toast notification toastManager?.showInfo(String(localized: "player.error.skippingToNext.title")) await playNext() } } }