Files
yattee/Yattee/Services/Player/PlayerService.swift

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