Files
yattee/Yattee/Services/Player/PlayerService.swift
Arkadiusz Fal 100e762d4b Suppress stale player error after switching videos mid-retry
If the MPV backend was retrying a failed load and the user switched to
another video before retries exhausted, the eventual error was published
to the player UI even though that video was no longer active. Guard the
catch block with a loadingVideoID check so stale errors are dropped.
2026-05-10 15:28:11 +02:00

2739 lines
124 KiB
Swift

//
// 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] = []
/// Original (unproxied) streams from the API, used to re-apply proxy when settings change.
private var originalStreams: [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<Void, Never>?
/// 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 = []
originalStreams = []
// 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<Void, Never>?
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)
CachedChannelData.cacheAuthor(fullVideo.author)
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 {
// If the user has already switched to a different video while this one was
// still loading/retrying, suppress the error it no longer applies to what
// is on screen. loadingVideoID is reassigned by every new play() call and
// cleared on success, so a mismatch means we are no longer the active load.
guard loadingVideoID == video.id else {
LoggingService.shared.logPlayer("Ignoring stale playback error for \(video.id.id) — user switched videos")
sleepPreventionService.allowSleep()
return
}
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 = []
originalStreams = []
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
#if os(tvOS)
LoggingService.shared.debug("PlayerService[tvOS-bg]: phase=\(phase) bgEnabled=\(backgroundEnabled) playbackState=\(state.playbackState)", category: .player)
if (phase == .background || phase == .inactive), !backgroundEnabled, state.playbackState == .playing {
LoggingService.shared.debug("PlayerService[tvOS-bg]: pausing playback (phase=\(phase), backgroundPlaybackEnabled=false)", category: .player)
pause()
}
#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 loaded, optionally seek/resume instead of reloading.
if isCurrentlyPlaying(video: video) {
LoggingService.shared.logPlayer("Video already loaded, applying seek/resume instead of reload (startTime=\(startTime ?? -1), state=\(state.playbackState))")
if !mpvPiPActive {
navigationCoordinator?.expandPlayer()
}
let wasPaused = state.playbackState == .paused
if let startTime {
Task { @MainActor in
await seek(to: startTime)
if wasPaused || state.playbackState == .paused {
resume()
}
}
} else if wasPaused {
resume()
}
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 loaded, optionally resume instead of reloading.
if isCurrentlyPlaying(video: video) {
if !mpvPiPActive {
navigationCoordinator?.expandPlayer()
}
if state.playbackState == .paused {
resume()
}
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)
// Legacy video IDs for `.localFolder` sources may carry an absolute container
// path (recorded before relative paths were computed correctly). Normalize
// against the source folder name so the path becomes relative and doesn't
// get appended onto the freshly resolved bookmark base, which would produce
// a doubled `/Documents/<folder>/private/var/.../Documents/<folder>/...` path.
let normalizedFilePath: String
if source.type == .localFolder {
normalizedFilePath = LocalFileClient.normalizeAbsolutePath(filePath, sourceFolderName: source.url.lastPathComponent)
} else {
normalizedFilePath = filePath
}
let parentPath = (normalizedFilePath as NSString).deletingLastPathComponent
let fileName = (normalizedFilePath 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)
#if os(tvOS)
nowPlayingService.refreshSystemControlsForCurrentAudioRoute(reason: "audio session activation")
#endif
// 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, [])
}
// Race the proxy-detection HEAD against the video API call so the
// verdict is (usually) ready by the time streams come back. Cheap
// when there's nothing to do it returns immediately if the
// verdict is already cached, or if no prior CDN sample exists.
async let _: Void = InvidiousAPI.prewarmProxyDetection(for: instance)
// 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)
// Store original (unproxied) streams so we can re-apply proxy when settings change
self.originalStreams = result.streams
// Apply proxy URL rewriting for instances that support it
let streams = await InvidiousAPI.proxyStreamsIfNeeded(result.streams, instance: instance)
return (result.video, streams, result.captions, result.storyboards)
}
/// Re-applies proxy URL rewriting to cached streams when instance settings change.
/// Uses the stored `originalStreams` to derive the correct URLs without re-fetching from the API.
private func reapplyProxyToStreams() async {
guard !originalStreams.isEmpty,
let video = state.currentVideo,
let instance = try? await findInstance(for: video) else { return }
let newStreams = await InvidiousAPI.proxyStreamsIfNeeded(originalStreams, instance: instance)
// Update available streams (preserve any downloaded/local file streams)
let downloadedStreams = availableStreams.filter { $0.url.isFileURL }
availableStreams = downloadedStreams + newStreams
// Update current stream URL if it changed
if let currentStream = state.currentStream,
let matchingNew = newStreams.first(where: { $0.resolution == currentStream.resolution && $0.format == currentStream.format }) {
if matchingNew.url != currentStream.url {
state.updateCurrentStream(matchingNew)
LoggingService.shared.logPlayer("Updated current stream URL after proxy setting change")
}
}
// Update current audio stream URL if it changed
if let currentAudio = state.currentAudioStream,
let matchingNew = newStreams.first(where: { $0.resolution == currentAudio.resolution && $0.format == currentAudio.format && $0.isAudioOnly == true }) {
if matchingNew.url != currentAudio.url {
state.updateCurrentAudioStream(matchingNew)
LoggingService.shared.logPlayer("Updated current audio stream URL after proxy setting change")
}
}
LoggingService.shared.logPlayer("Re-applied proxy settings: \(newStreams.count) streams updated")
}
/// 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[..<separatorIndex])) {
// For WebDAV sources, add authentication headers
if extractor == MediaFile.webdavProvider {
if let source = mediaSourcesManager?.sources.first(where: { $0.id == sourceUUID }) {
if let username = source.username,
let password = mediaSourcesManager?.password(for: source) {
let credentials = "\(username):\(password)"
if let credentialsData = credentials.data(using: .utf8) {
let base64Credentials = credentialsData.base64EncodedString()
headers = ["Authorization": "Basic \(base64Credentials)"]
LoggingService.shared.logPlayer("Media source: Created WebDAV stream with auth for '\(source.name)'")
}
} else {
LoggingService.shared.logPlayer("Media source: Created WebDAV stream without auth (no credentials) for '\(source.name)'")
}
} else {
LoggingService.shared.logPlayer("Media source: WebDAV source not found for UUID \(sourceUUID)")
}
}
// For SMB sources, construct URL with embedded credentials
else if extractor == MediaFile.smbProvider {
if let source = mediaSourcesManager?.sources.first(where: { $0.id == sourceUUID }) {
// Reconstruct MediaFile from video to use SMBClient
let path = String(videoID.dropFirst(sourceUUID.uuidString.count + 1))
let mediaFile = MediaFile(
source: source,
path: path,
name: url.lastPathComponent,
isDirectory: false
)
let password = mediaSourcesManager?.password(for: source)
if let smbClient {
playbackURL = try await smbClient.constructPlaybackURL(
for: mediaFile,
source: source,
password: password
)
} else {
LoggingService.shared.logPlayer("Media source: SMBClient not available, using original URL")
}
LoggingService.shared.logPlayer("Media source: Created SMB stream with embedded auth for '\(source.name)' - URL: \(playbackURL.sanitized)")
} else {
LoggingService.shared.logPlayer("Media source: SMB source not found for UUID \(sourceUUID)")
}
}
// Local folder
else {
LoggingService.shared.logPlayer("Media source: Created local folder stream")
}
} else {
LoggingService.shared.logPlayer("Media source: Could not parse UUID from videoID \(videoID)")
}
// Create stream with placeholder resolution and audio codec so it's recognized as muxed
// (actual values are unknown until playback, but MPV handles all formats)
return Stream(
url: playbackURL,
resolution: .p720, // Placeholder - actual resolution unknown
format: url.pathExtension,
videoCodec: "avc1", // Placeholder to indicate video content
audioCodec: "aac", // Placeholder to mark as muxed (has audio)
httpHeaders: headers
)
}
private func selectStreamAndBackend(from streams: [Stream]) -> (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,
// unless the user opted in to software-decoded formats.
let streamsToConsider: [Stream]
if settingsManager?.allowSoftwareDecodedFormats ?? false {
streamsToConsider = filteredVideoStreams
} else {
let hardwareDecodableStreams = filteredVideoStreams.filter { videoCodecPriority($0.videoCodec) > 0 }
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<String> = []
/// Sets the instances manager for finding instances.
func setInstancesManager(_ manager: InstancesManager) {
self.instancesManager = manager
// Observe instance setting changes (e.g. proxy toggle) to re-apply proxy to cached streams
instanceChangeObserver = NotificationCenter.default.addObserver(
forName: .instancesDidChange,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor [weak self] in
await self?.reapplyProxyToStreams()
}
}
}
/// 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 instance setting changes to re-apply proxy to cached streams.
private var instanceChangeObserver: NSObjectProtocol?
/// 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()
}
}
}