//
//  PlayerService.swift
//  Yattee
//
//  Service managing video playback with pluggable backends.
//

import Foundation
import AVFoundation
import Combine
import SwiftUI

/// Protocol for player service delegate callbacks.
@MainActor
protocol PlayerServiceDelegate: AnyObject {
    func playerService(_ service: PlayerService, didUpdateTime time: TimeInterval)
    func playerService(_ service: PlayerService, didChangeState state: PlaybackState)
    func playerService(_ service: PlayerService, didEncounterError error: Error)
    func playerService(_ service: PlayerService, shouldSkipSegment segment: SponsorBlockSegment) -> Bool
    func playerServiceDidFinishPlaying(_ service: PlayerService)
}

/// Main service for video playback.
@MainActor
@Observable
final class PlayerService {
    // MARK: - Properties

    /// The current player backend.
    private(set) var currentBackend: (any PlayerBackend)?

    /// The type of backend currently in use.
    var currentBackendType: PlayerBackendType {
        currentBackend?.backendType ?? preferredBackendType
    }

    /// MPV version information (available when MPV backend is initialized).
    var mpvVersionInfo: MPVVersionInfo? {
        (currentBackend as? MPVBackend)?.versionInfo
    }

    /// User's preferred backend type (reads directly from settings for live updates).
    var preferredBackendType: PlayerBackendType {
        settingsManager?.preferredBackend ?? .mpv
    }

    /// Observable player state.
    let state: PlayerState

    /// Delegate for callbacks.
    weak var delegate: PlayerServiceDelegate?

    /// Available streams for the current video.
    private(set) var availableStreams: [Stream] = []

    /// Original (unproxied) streams from the API, used to re-apply proxy when settings change.
    private var originalStreams: [Stream] = []

    /// Available captions for the current video.
    private(set) var availableCaptions: [Caption] = []

    /// Currently loaded caption.
    private(set) var currentCaption: Caption?

    /// Tracks reported by mpv for the currently loaded file (embedded and external).
    private(set) var embeddedTracks: [MPVTrack] = []

    /// Embedded (in-container) audio tracks of the current file.
    var embeddedAudioTracks: [MPVTrack] {
        embeddedTracks.filter { $0.type == .audio && !$0.isExternal }
    }

    /// Embedded (in-container) subtitle tracks of the current file.
    var embeddedSubtitleTracks: [MPVTrack] {
        embeddedTracks.filter { $0.type == .sub && !$0.isExternal }
    }

    /// The embedded audio track mpv currently plays, if any.
    var selectedEmbeddedAudioTrackID: Int? {
        embeddedAudioTracks.first(where: \.isSelected)?.trackID
    }

    /// The embedded subtitle track mpv currently shows, if any.
    var selectedEmbeddedSubtitleTrackID: Int? {
        embeddedSubtitleTracks.first(where: \.isSelected)?.trackID
    }

    /// The video track mpv currently plays. Used to label local-file streams,
    /// whose Stream carries no resolution/codec/fps metadata before demux.
    var primaryEmbeddedVideoTrack: MPVTrack? {
        let videoTracks = embeddedTracks.filter { $0.type == .video && !$0.isExternal && !$0.isAlbumArt }
        return videoTracks.first(where: \.isSelected) ?? videoTracks.first
    }

    /// Embedded-track picks that should survive same-video reloads
    /// (quality switch, audio-mode toggle, buffer-stall retry).
    private var desiredEmbeddedAudioTrackID: Int?
    private var desiredEmbeddedSubtitleTrackID: Int?

    /// Whether preferred-language auto-selection already ran for this load.
    private var didAutoSelectEmbeddedTracks = false

    /// The current download being played, if any.
    private(set) var currentDownload: Download?

    /// Whether we're currently playing downloaded content.
    var isPlayingDownloadedContent: Bool {
        currentDownload != nil
    }

    /// Whether online streams are currently being loaded.
    private(set) var isLoadingOnlineStreams: Bool = false

    // MARK: - Dependencies

    private let httpClient: HTTPClient
    private let contentService: ContentService
    private let sponsorBlockAPI: SponsorBlockAPI
    private let returnYouTubeDislikeAPI: ReturnYouTubeDislikeAPI
    private let dataManager: DataManager
    private let backendFactory: BackendFactory
    private let backendSwitcher: BackendSwitcher
    private weak var settingsManager: SettingsManager?
    private weak var downloadManager: DownloadManager?
    private weak var navigationCoordinator: NavigationCoordinator?
    private weak var connectivityMonitor: ConnectivityMonitor?
    private weak var toastManager: ToastManager?
    private weak var queueManager: QueueManager?
    private weak var playerControlsLayoutService: PlayerControlsLayoutService?
    private weak var handoffManager: HandoffManager?

    // MARK: - Private State

    /// Current scene phase, updated by handleScenePhase
    private var currentScenePhase: ScenePhase = .active

    private var progressSaveTimer: Timer?
    private let progressSaveInterval: TimeInterval = 5

    /// Now Playing service for Control Center/Lock Screen.
    private let nowPlayingService = NowPlayingService()

    /// Service to prevent system sleep during playback.
    private let sleepPreventionService = SleepPreventionService()

    /// Tracks the last skipped segment to prevent duplicate skips.
    private var lastSkippedSegmentID: String?

    /// Tracks if the current video ended naturally (reached EOF).
    /// Used to prevent saving progress when switching to next video after natural completion.
    private var videoEndedNaturally = false

    /// Current playback task - cancelled when video is closed or new video is opened.
    private var currentPlayTask: Task<Void, Never>?

    /// Video ID that we're currently loading - used to ignore stale time updates from previous video.
    private var loadingVideoID: VideoID?

    /// Counter for stream refresh attempts to prevent infinite refresh loops.
    private var streamRefreshAttempts = 0
    private let maxStreamRefreshAttempts = 2

    /// Tracks if playback was interrupted by system (phone call, alarm, etc.)
    /// Used to auto-resume when interruption ends.
    private var wasInterrupted = false

    /// Tracks if audio session interruption observer has been registered.
    private var hasRegisteredInterruptionObserver = false

    // MARK: - Initialization

    init(
        httpClient: HTTPClient,
        contentService: ContentService,
        dataManager: DataManager,
        backendFactory: BackendFactory? = nil
    ) {
        self.httpClient = httpClient
        self.contentService = contentService
        self.dataManager = dataManager
        self.sponsorBlockAPI = SponsorBlockAPI(httpClient: httpClient)
        self.returnYouTubeDislikeAPI = ReturnYouTubeDislikeAPI(httpClient: httpClient)
        self.state = PlayerState()

        let factory = backendFactory ?? BackendFactory()
        self.backendFactory = factory
        // Note: settingsManager is set later via setSettingsManager, backendSwitcher will use it once available
        self.backendSwitcher = BackendSwitcher(backendFactory: factory, settingsManager: nil)

        // Link now playing service back to player
        nowPlayingService.playerService = self

        // Pre-warm backends asynchronously (non-blocking)
        // This starts OpenGL initialization in background immediately at app launch
        Task {
            await factory.prewarmAllBackends()
        }

        // Listen for memory warnings to drain backend pool
        #if os(iOS) || os(tvOS)
        NotificationCenter.default.addObserver(
            forName: .memoryWarning,
            object: nil,
            queue: .main
        ) { [weak factory] _ in
            Task { @MainActor [weak factory] in
                factory?.drainPool()
            }
        }
        #endif
    }

    deinit {
        MainActor.assumeIsolated {
            cleanup()
        }
    }

    // MARK: - Public Methods

    /// Prepares a video for playback without loading streams.
    /// Shows the video in the player with a play button overlay.
    /// Call `play(video:)` to actually start loading and playing.
    /// - Parameter video: The video to prepare
    func prepare(video: Video) {
        LoggingService.shared.logPlayer("Preparing video: \(video.id.id)")

        // Clear streams
        availableStreams = []
        originalStreams = []

        // Set video in state but keep idle (not loading)
        state.setCurrentVideo(video, stream: nil)
        state.setPlaybackState(.idle)
    }

    /// Plays a video with optional starting time.
    /// - Parameters:
    ///   - video: The video to play
    ///   - stream: Optional specific stream to use (if provided, skips fetching streams from API)
    ///   - audioStream: Optional separate audio stream (for video-only streams)
    ///   - startTime: Optional start time in seconds
    ///   - forceStartTime: When true, startTime is an explicit user request (timed link,
    ///     chapter tap) and is honored even past the completion threshold
    func play(video: Video, stream: Stream? = nil, audioStream: Stream? = nil, startTime: TimeInterval? = nil, forceStartTime: Bool = false) async {
        // Downloaded/local files bypass stream selection (they arrive here as
        // ready-made file:// streams), so audio mode is applied at this choke
        // point instead of in selectStreams.
        var stream = stream
        var audioStream = audioStream
        if let provided = stream {
            (stream, audioStream) = applyingAudioModeToLocalStreams(video: video, stream: provided, audioStream: audioStream)
        }

        // Set up audio session when playback actually starts (not at app launch)
        setupAudioSession()

        let isNewVideo = state.currentVideo?.id != video.id

        // Save progress and sync for previous video before switching
        // Skip if video ended naturally - 100% was already saved in backendDidFinishPlaying
        if state.currentVideo != nil && isNewVideo && !videoEndedNaturally {
            saveProgressAndSync()
        }

        // Reset flag for the new video
        videoEndedNaturally = false

        // Clear the previous video's track list when transitioning to a new
        // video so the player settings sheet doesn't show stale tracks.
        // Skipped for same-video calls (e.g. in-video quality switch) so the
        // full stream list kept around for the picker survives the switch.
        if isNewVideo {
            availableStreams = []
        }

        // Embedded-track state: the mpv-reported list always refreshes on load,
        // but sticky user picks only reset when the video actually changes.
        embeddedTracks = []
        didAutoSelectEmbeddedTracks = false
        if isNewVideo {
            desiredEmbeddedAudioTrackID = nil
            desiredEmbeddedSubtitleTrackID = nil
        }

        // 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

        // Release the gate on any exit path (a newer play() claims it with its own
        // id, so only clear our own). Prevents the time-update gate from leaking if
        // the load flow exits early (issue #956).
        defer {
            if loadingVideoID == video.id { loadingVideoID = nil }
        }

        state.setPlaybackState(.loading)
        state.isFirstFrameReady = false  // Reset until first frame of new video is rendered
        state.isBufferReady = false  // Reset until buffer is ready for smooth playback
        state.setCurrentVideo(video, stream: stream, audioStream: audioStream)

        // Start fetching SponsorBlock segments early (in parallel with stream loading)
        // so we have them ready before playback starts
        var sponsorBlockTask: Task<Void, Never>?
        if stream == nil, case .global = video.id.source, settingsManager?.sponsorBlockEnabled == true {
            sponsorBlockTask = Task {
                await fetchSponsorBlockSegments(for: video.id.videoID)
            }
        }

        do {
            let selectedStream: Stream
            let selectedAudioStream: Stream?
            let backendType: PlayerBackendType

            // If a stream is provided (e.g., local/downloaded file or quality switch), use it directly
            if let providedStream = stream {
                selectedStream = providedStream
                selectedAudioStream = audioStream
                backendType = preferredBackendType
                // Only set availableStreams if empty (preserve existing streams during quality switch)
                if availableStreams.isEmpty {
                    self.availableStreams = [providedStream]
                }
                state.setCurrentVideo(video, stream: providedStream, audioStream: audioStream)
                // Mark details as loaded since we're using the video as-is
                state.videoDetailsState = .loaded
                lockDurationIfNeeded(for: video, stream: providedStream)

                // Load local storyboard if this is a downloaded video
                if let downloadManager,
                   let download = downloadManager.download(for: video.id),
                   let storyboard = download.storyboard,
                   let storyboardPath = download.localStoryboardPath {
                    let storyboardDir = downloadManager.downloadsDirectory().appendingPathComponent(storyboardPath)
                    let localStoryboard = Storyboard.localStoryboard(from: storyboard, localDirectory: storyboardDir)
                    Task { await StoryboardService.shared.clearCache() }
                    state.storyboards = [localStoryboard]
                    LoggingService.shared.logPlayer("Loaded local storyboard for \(video.id.id)")
                }
            } else {
                // Mark that we're loading video details from API
                state.videoDetailsState = .loading

                // Fetch full video details (includes description), streams, captions, and storyboards in one API call
                let (fullVideo, streams, captions, storyboards) = try await fetchVideoStreamsAndCaptionsAndStoryboards(for: video)

                // Check for cancellation after network fetch
                try Task.checkCancellation()

                self.availableStreams = streams
                self.availableCaptions = captions

                // Update storyboards and clear cached sprite sheets
                Task { await StoryboardService.shared.clearCache() }
                state.storyboards = storyboards
                LoggingService.shared.logPlayer("Loaded \(storyboards.count) storyboards, preferred: \(state.preferredStoryboard?.width ?? 0)x\(state.preferredStoryboard?.height ?? 0)")

                // Select best stream for preferred backend
                let selection = selectStreamAndBackend(from: streams)

                guard let selected = selection.stream else {
                    throw APIError.noStreams
                }

                selectedStream = selected
                selectedAudioStream = selection.audioStream
                backendType = selection.backend

                // Update state with full video details and selected stream
                state.setCurrentVideo(fullVideo, stream: selectedStream, audioStream: selectedAudioStream)
                CachedChannelData.cacheAuthor(fullVideo.author)
                state.videoDetailsState = .loaded
                lockDurationIfNeeded(for: fullVideo, stream: selectedStream)

                // Notify observers that full video details are now available
                NotificationCenter.default.post(name: .videoDetailsDidLoad, object: nil)

                // Auto-load preferred subtitle if set and will use MPV backend
                if backendType == .mpv,
                   let preferredLanguage = settingsManager?.preferredSubtitlesLanguage,
                   !preferredLanguage.isEmpty {
                    // Find matching caption (will be loaded after backend is ready)
                    if let preferredCaption = captions.first(where: { caption in
                        caption.baseLanguageCode == preferredLanguage ||
                        caption.languageCode.hasPrefix(preferredLanguage)
                    }) {
                        // Store for later loading after backend is created
                        currentCaption = preferredCaption
                    }
                }
            }

            // Audio-only streams never report a video size, so the previous
            // video's aspect ratio would stick to the player area forever.
            // Reset it so the UI falls back to the standard 16:9.
            if selectedStream.isAudioOnly {
                state.videoAspectRatio = nil
            }

            // 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 video.isLive || selectedStream.isLive {
                // Live playback positions are relative to the live edge, so a stored
                // or passed position means nothing - always start at the live edge.
                seekTime = 0
            } else 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 forceStartTime {
                    // User-requested timestamp (timed link, chapter tap) - always honor it,
                    // clamped so an out-of-range value can't seek past the end.
                    seekTime = effectiveDuration > 0 ? min(startTime, max(0, effectiveDuration - 1)) : startTime
                } else 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()

            if seekTime > 0 {
                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)
                }
                // Reliable (hqdefault) variant: artwork is a single fetch with no
                // fallback, and the best advertised variant often 404s.
                await nowPlayingService.loadArtwork(
                    from: videoForNowPlaying.reliableThumbnailURL,
                    localPath: localThumbnailURL
                )
            }

            // Start playback
            LoggingService.shared.logPlayer("Calling backend.play(), playbackState: \(state.playbackState)")
            loadingVideoID = nil  // Clear loading flag - time updates are now valid
            updateSleepPrevention()

            // For MPV backend, wait for sufficient buffer before starting playback
            // This prevents the brief pause/stutter that occurs when MPV starts playing
            // before enough content is buffered
            // Skip buffer wait for local files - they don't need network buffering
            let isLocalFile = selectedStream.url.isFileURL
            if !isLocalFile, let mpvBackend = backend as? MPVBackend {
                let bufferTime = settingsManager?.mpvBufferSeconds ?? SettingsManager.defaultMpvBufferSeconds
                _ = await mpvBackend.waitForBuffer(minimumBuffer: bufferTime)
            }

            // Buffer is ready - thumbnail can now hide
            state.isBufferReady = true
            state.bufferProgress = nil  // Clear buffer progress since buffering is complete

            // Give SwiftUI time to process the isBufferReady state change and complete
            // the thumbnail fade-out animation (200ms) before audio starts playing.
            // Without this, audio may start while thumbnail is still visible.
            try? await Task.sleep(nanoseconds: 250_000_000)  // 250ms (animation is 200ms)

            backend.play()

            // Update Handoff activity for cross-device continuation
            handoffManager?.updateActivity(for: .video(.id(video.id)))

            // Fetch dislike counts for YouTube videos (only for online content)
            // Note: SponsorBlock is fetched earlier in parallel with stream loading
            // Note: Captions are now fetched together with video details and streams
            if stream == nil, case .global = video.id.source {
                await fetchReturnYouTubeDislikeCounts(for: video.id.videoID)
            }

            // Load preferred subtitle if one was selected earlier (for MPV backend)
            if let caption = currentCaption, let mpvBackend = backend as? MPVBackend {
                mpvBackend.loadCaption(caption)
                LoggingService.shared.logPlayer("Auto-loaded preferred subtitle: \(caption.displayName)")
            }

            // Start progress saving
            startProgressSaveTimer()

            // Notify queue manager that video started (for proactive continuation loading)
            notifyVideoStarted()

            LoggingService.shared.logPlayer("Playback started: \(video.id.id)", details: "Stream: \(selectedStream.qualityLabel)")

        } catch is CancellationError {
            // Load was cancelled because a new video was selected - don't report as error
            LoggingService.shared.logPlayer("Playback cancelled: \(video.id.id)")
            sleepPreventionService.allowSleep()
        } catch {
            // If the user has already switched to a different video while this one was
            // still loading/retrying, suppress the error — it no longer applies to what
            // is on screen. loadingVideoID is reassigned by every new play() call and
            // cleared on success, so a mismatch means we are no longer the active load.
            guard loadingVideoID == video.id else {
                LoggingService.shared.logPlayer("Ignoring stale playback error for \(video.id.id) — user switched videos")
                sleepPreventionService.allowSleep()
                return
            }

            LoggingService.shared.logPlayerError("Playback failed: \(video.id.id)", error: error)
            sleepPreventionService.allowSleep()
            state.videoDetailsState = .error
            state.setPlaybackState(.failed(error))
            delegate?.playerService(self, didEncounterError: error)

            // Auto-skip to next video if queue is not empty and player is not visible
            await handlePlaybackErrorAutoSkip()
        }
    }

    /// Reconciles sleep prevention with what is actually on screen: display sleep
    /// is only prevented when a video track is rendered. Audio-only playback
    /// lets the screen sleep normally.
    private func updateSleepPrevention() {
        if state.currentStream?.isAudioOnly == true {
            sleepPreventionService.allowSleep()
        } else {
            sleepPreventionService.preventSleep()
        }
    }

    /// 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() {
        updateSleepPrevention()
        currentBackend?.play()
        state.setPlaybackState(.playing)
        nowPlayingService.updatePlaybackRate(isPlaying: true, currentTime: state.currentTime)
    }

    /// Toggles play/pause.
    func togglePlayPause() {
        if state.playbackState == .playing {
            pause()
        } else if state.playbackState == .ended {
            // Restart from beginning when video has ended
            Task {
                await seek(to: 0)
                resume()
            }
        } else {
            resume()
        }
    }

    /// Stops playback and clears the player.
    func stop() {
        sleepPreventionService.allowSleep()

        if let video = state.currentVideo {
            LoggingService.shared.logPlayer("Stopping playback: \(video.id.id)")
        }

        // Cancel any ongoing stream loading
        currentPlayTask?.cancel()
        currentPlayTask = nil

        saveProgressAndSync()
        cleanup()

        // Clear Handoff activity
        handoffManager?.invalidateCurrentActivity()

        // Clear video first, then reset (reset clears isClosingVideo)
        state.setCurrentVideo(nil, stream: nil)
        state.reset()
        state.clearHistory()
        availableStreams = []
        originalStreams = []
        availableCaptions = []
        folderFilesCache.removeAll()
        downloadedSubtitlesCache.removeAll()
        preDownloadedSubtitleFolders.removeAll()
        cleanupAllTempSubtitles()
        currentCaption = nil
        embeddedTracks = []
        desiredEmbeddedAudioTrackID = nil
        desiredEmbeddedSubtitleTrackID = nil
        didAutoSelectEmbeddedTracks = false
        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 {
            updateSleepPrevention()
        }

        #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

        #if os(iOS)
        let isPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
        #else
        let isPiPActive = false
        #endif

        // With background playback disabled, pause when the app leaves the
        // foreground — the audio background mode would otherwise keep mpv
        // playing. Not applicable on macOS, where apps are never suspended.
        #if os(iOS)
        let backgroundEnabled = settingsManager?.backgroundPlaybackEnabled ?? true
        if phase == .background, !backgroundEnabled, !isPiPActive, state.playbackState == .playing {
            LoggingService.shared.debug("PlayerService: pausing playback (phase=\(phase), backgroundPlaybackEnabled=false)", category: .player)
            pause()
        }
        #elseif os(tvOS)
        let backgroundEnabled = settingsManager?.backgroundPlaybackEnabled ?? true
        LoggingService.shared.debug("PlayerService[tvOS-bg]: phase=\(phase) bgEnabled=\(backgroundEnabled) playbackState=\(state.playbackState)", category: .player)
        if (phase == .background || phase == .inactive), !backgroundEnabled, state.playbackState == .playing {
            LoggingService.shared.debug("PlayerService[tvOS-bg]: pausing playback (phase=\(phase), backgroundPlaybackEnabled=false)", category: .player)
            pause()
        }
        #endif

        currentBackend?.handleScenePhase(phase, 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
            var stream = queuedVideo.stream
            var audioStream = queuedVideo.audioStream
            if let preResolved = stream, !preResolved.url.isFileURL,
               preResolved.isAudioOnly != (settingsManager?.audioOnlyModeEnabled == true) {
                // Pre-resolved before an audio-mode toggle - discard so selection re-runs
                stream = nil
                audioStream = nil
            }
            await play(video: queuedVideo.video, stream: stream, audioStream: 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)
                }
            }
            // Fetch the full stream/caption list in the background so the
            // quality / audio / subtitle picker isn't limited to the single
            // pre-resolved stream that the queue item carried.
            if case .global = queuedVideo.video.id.source {
                Task { [weak self] in
                    await self?.loadOnlineStreams()
                }
            }
        }
    }

    /// 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,
        forceStartTime: Bool = false
    ) 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, forceStartTime: forceStartTime)

                // 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, forceStartTime: forceStartTime)
                // 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, forceStartTime: forceStartTime)
            }
        } else {
            currentDownload = nil
            await play(video: video, stream: fallbackStream, audioStream: fallbackAudioStream, startTime: startTime, forceStartTime: forceStartTime)
        }
    }

    /// 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)
    ///   - forceStartTime: When true, startTime is an explicit user request (timed link,
    ///     chapter tap) and is honored even past the completion threshold
    func openVideo(_ video: Video, startTime: TimeInterval? = nil, forceStartTime: Bool = false) {
        // Live streams have no meaningful resume position - drop any passed one
        // so callers with a stale watch entry cannot seek into the live window.
        let startTime = video.isLive ? nil : startTime

        // Check if MPV PiP is active - if so, don't expand the player
        #if os(iOS) || os(macOS)
        let mpvPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
        #else
        let mpvPiPActive = false
        #endif

        // If this video is already loaded, optionally seek/resume instead of reloading.
        if isCurrentlyPlaying(video: video) {
            LoggingService.shared.logPlayer("Video already loaded, applying seek/resume instead of reload (startTime=\(startTime ?? -1), state=\(state.playbackState))")
            if !mpvPiPActive {
                navigationCoordinator?.expandPlayer()
            }
            let wasPaused = state.playbackState == .paused
            if let startTime {
                Task { @MainActor in
                    await seek(to: startTime)
                    if wasPaused || state.playbackState == .paused {
                        resume()
                    }
                }
            } else if wasPaused {
                resume()
            }
            return
        }

        // Cancel any previous play task before starting a new one
        currentPlayTask?.cancel()

        // Set video info before expanding so the sheet animates with content visible
        state.setPlaybackState(.loading)
        state.setCurrentVideo(video, stream: nil)

        // Expand player immediately so it opens while loading (unless PiP is active)
        if !mpvPiPActive {
            navigationCoordinator?.expandPlayer()
        }

        currentPlayTask = Task {
            await playPreferringDownloaded(video: video, startTime: startTime, forceStartTime: forceStartTime)
        }
    }

    /// 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) || os(macOS)
        let mpvPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
        #else
        let mpvPiPActive = false
        #endif

        // If this video is already loaded, optionally resume instead of reloading.
        if isCurrentlyPlaying(video: video) {
            if !mpvPiPActive {
                navigationCoordinator?.expandPlayer()
            }
            if state.playbackState == .paused {
                resume()
            }
            return
        }

        // Cancel any previous play task before starting a new one
        currentPlayTask?.cancel()

        // Set video info before expanding so the sheet animates with content visible
        state.setPlaybackState(.loading)
        state.setCurrentVideo(video, stream: stream, audioStream: audioStream)

        // Expand player immediately so it opens while loading (unless PiP is active)
        if !mpvPiPActive {
            navigationCoordinator?.expandPlayer()
        }

        // Try to look up download if not provided but playing local file
        var resolvedDownload = download
        if resolvedDownload == nil, let downloadManager {
            resolvedDownload = downloadManager.download(for: video.id)
        }

        // Store download for reference
        currentDownload = resolvedDownload

        LoggingService.shared.logPlayer("openVideo with stream called - download: \(resolvedDownload != nil ? "present" : "nil")")

        currentPlayTask = Task {
            await play(video: video, stream: stream, audioStream: audioStream)
            // Note: Storyboards are now loaded in play() for all downloaded videos

            // Load local caption if download has caption
            if let resolvedDownload,
               let captionPath = resolvedDownload.localCaptionPath,
               let downloadManager {
                let captionURL = downloadManager.downloadsDirectory().appendingPathComponent(captionPath)
                if FileManager.default.fileExists(atPath: captionURL.path) {
                    LoggingService.shared.logPlayer("Loading local caption: \(captionPath)")
                    loadLocalCaption(url: captionURL)
                }
            }

            // Set external captions (e.g., from WebDAV subtitle files)
            if !captions.isEmpty {
                LoggingService.shared.logPlayer("Setting \(captions.count) external caption(s)")
                self.availableCaptions = captions

                // Auto-select based on preferred language setting
                if let preferred = settingsManager?.preferredSubtitlesLanguage,
                   let match = captions.first(where: { $0.baseLanguageCode == preferred || $0.languageCode.hasPrefix(preferred) }) {
                    LoggingService.shared.logPlayer("Auto-selecting caption: \(match.displayName)")
                    loadCaption(match)
                }
            }
        }
    }

    /// Plays the previous video in queue.
    /// If more than 3 seconds into current video, restarts it instead.
    func playPrevious() async {
        // Pause without saving progress if video ended - 100% was already saved in backendDidFinishPlaying
        pause(saveProgress: state.playbackState != .ended)

        // If more than 3 seconds in, restart current video
        if state.currentTime > 3 {
            await seek(to: 0)
            resume()
            return
        }

        // Try to go back to previous video in history
        if let previous = state.retreatQueue() {
            // Push current video to front of queue (starts at 0 when replayed)
            if let currentVideo = state.currentVideo {
                state.insertNext(currentVideo, stream: state.currentStream, audioStream: state.currentAudioStream)
            }

            var stream = previous.stream
            var audioStream = previous.audioStream
            if let preResolved = stream, !preResolved.url.isFileURL,
               preResolved.isAudioOnly != (settingsManager?.audioOnlyModeEnabled == true) {
                // Resolved before an audio-mode toggle - discard so selection re-runs
                stream = nil
                audioStream = nil
            }

            // Play the previous video, resuming from saved position
            await play(video: previous.video, stream: stream, audioStream: 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: Stream
            let newAudioStream: Stream?

            if state.currentStream?.isAudioOnly == true || settingsManager?.audioOnlyModeEnabled == true,
               let refreshedAudio = findMatchingAudioStream(in: streams, preferring: state.currentStream)
            {
                // Preserve audio-only playback (audio mode or audio-only content);
                // `findMatchingAudioStream` matches the current track's language
                newStream = refreshedAudio
                newAudioStream = nil
            } else {
                newStream = findMatchingStream(in: streams, preferring: state.currentStream)
                newAudioStream = newStream.isVideoOnly
                    ? findMatchingAudioStream(in: streams, preferring: state.currentAudioStream)
                    : 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
        // An explicit external pick (or Off) overrides any embedded-subtitle intent
        desiredEmbeddedSubtitleTrackID = nil

        if let caption {
            LoggingService.shared.logPlayer("Loaded caption: \(caption.displayName)")
        } else {
            LoggingService.shared.logPlayer("Disabled subtitles")
        }
    }

    /// Selects an embedded audio track by mpv track id (live, no reload).
    /// Only works with MPV backend.
    func selectEmbeddedAudioTrack(_ trackID: Int) {
        guard let mpvBackend = currentBackend as? MPVBackend else { return }

        desiredEmbeddedAudioTrackID = trackID
        mpvBackend.selectEmbeddedAudioTrack(trackID)
        LoggingService.shared.logPlayer("Selected embedded audio track \(trackID)")
    }

    /// Selects an embedded subtitle track by mpv track id (nil = off).
    /// Only works with MPV backend.
    func selectEmbeddedSubtitleTrack(_ trackID: Int?) {
        guard let mpvBackend = currentBackend as? MPVBackend else { return }

        desiredEmbeddedSubtitleTrackID = trackID
        currentCaption = nil
        mpvBackend.selectEmbeddedSubtitleTrack(trackID)
        LoggingService.shared.logPlayer("Selected embedded subtitle track \(trackID.map(String.init) ?? "off")")
    }

    /// Re-applies a sticky embedded-track pick after a reload, or auto-selects
    /// preferred languages once per load. Called whenever mpv's track list changes.
    private func reapplyOrAutoSelectEmbeddedTracks() {
        guard let mpvBackend = currentBackend as? MPVBackend else { return }

        // Re-apply sticky user picks. Loop-safe: selecting fires one more
        // track-list update in which the track is already selected.
        if let desired = desiredEmbeddedAudioTrackID,
           let track = embeddedAudioTracks.first(where: { $0.trackID == desired }),
           !track.isSelected {
            mpvBackend.selectEmbeddedAudioTrack(desired)
        }
        if let desired = desiredEmbeddedSubtitleTrackID,
           let track = embeddedSubtitleTracks.first(where: { $0.trackID == desired }),
           !track.isSelected {
            mpvBackend.selectEmbeddedSubtitleTrack(desired)
        }

        // Auto-select preferred languages once per load. Skipped while the
        // list is empty so the reset delivered at load start doesn't consume
        // the one-shot flag.
        guard !didAutoSelectEmbeddedTracks, !embeddedTracks.isEmpty else { return }
        didAutoSelectEmbeddedTracks = true

        if desiredEmbeddedAudioTrackID == nil,
           embeddedAudioTracks.count > 1,
           let match = embeddedAudioTracks.first(where: { $0.matchesLanguage(settingsManager?.preferredAudioLanguage) }),
           !match.isSelected {
            mpvBackend.selectEmbeddedAudioTrack(match.trackID)
            LoggingService.shared.logPlayer("Auto-selected embedded audio track \(match.trackID) (\(match.displayName))")
        }

        // External captions win: the preferred-caption flow sets currentCaption
        // before mpv reports any tracks.
        if desiredEmbeddedSubtitleTrackID == nil,
           currentCaption == nil,
           let match = embeddedSubtitleTracks.first(where: { !$0.isForced && $0.matchesLanguage(settingsManager?.preferredSubtitlesLanguage) }),
           !match.isSelected {
            mpvBackend.selectEmbeddedSubtitleTrack(match.trackID)
            LoggingService.shared.logPlayer("Auto-selected embedded subtitle track \(match.trackID) (\(match.displayName))")
        }
    }

    /// 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 a user-picked external subtitle file (iOS document picker / macOS open panel).
    /// Copies the file into the per-video temp subtitle directory (so mpv can read it
    /// after the picker's security scope expires), registers it as a selectable
    /// Caption for the current video, and activates it.
    /// - Parameter pickedURL: The URL returned by the file picker.
    func loadExternalSubtitleFile(from pickedURL: URL) {
        guard let video = state.currentVideo, video.isFromMediaSource else { return }

        let fileName = pickedURL.lastPathComponent
        let didStartAccessing = pickedURL.startAccessingSecurityScopedResource()
        defer {
            if didStartAccessing {
                pickedURL.stopAccessingSecurityScopedResource()
            }
        }

        // Same hash-based directory scheme as SMBClient's subtitle pre-download:
        // media-source video IDs contain slashes, so they can't be path components.
        let tempDir = FileManager.default.temporaryDirectory
            .appendingPathComponent("yattee-subtitles", isDirectory: true)
            .appendingPathComponent(String(video.id.id.hashValue), isDirectory: true)
        let destinationURL = tempDir.appendingPathComponent("picked-\(fileName)")

        do {
            try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
            if FileManager.default.fileExists(atPath: destinationURL.path) {
                try FileManager.default.removeItem(at: destinationURL)
            }
            try FileManager.default.copyItem(at: pickedURL, to: destinationURL)
        } catch {
            LoggingService.shared.error(
                "Failed to copy picked subtitle file \(fileName): \(error.localizedDescription)",
                category: .player
            )
            return
        }

        // Detect language from a filename suffix like "Movie.en.srt" or "Movie_en.srt"
        let baseName = pickedURL.deletingPathExtension().lastPathComponent
        var languageCode = "und"
        if let suffix = baseName.components(separatedBy: CharacterSet(charactersIn: "._")).last,
           (2...3).contains(suffix.count),
           Locale.current.localizedString(forLanguageCode: suffix) != nil {
            languageCode = suffix.lowercased()
        }

        let caption = Caption(
            label: baseName,
            languageCode: languageCode,
            url: destinationURL,
            pickedFileName: fileName
        )

        // Re-picking the same file replaces its row instead of duplicating it
        availableCaptions.removeAll { $0.id == caption.id }
        availableCaptions.append(caption)

        loadCaption(caption)
        LoggingService.shared.logPlayer("Loaded external subtitle file: \(fileName)")
    }

    /// 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, storyboards) = try await fetchVideoStreamsAndCaptionsAndStoryboards(for: video)

            // Bail if the user moved on to another video while we were fetching
            guard state.currentVideo?.id == video.id else { return }

            // Combine downloaded streams with online streams (downloaded first)
            let combined = downloadedStreams + streams
            availableStreams = combined
            availableCaptions = captions

            // Queue items carry a pre-resolved stream, so play() skipped the
            // full fetch that populates storyboards — fill them in here.
            // Don't clobber a local storyboard loaded for downloaded playback.
            if state.storyboards.isEmpty, !storyboards.isEmpty {
                Task { await StoryboardService.shared.clearCache() }
                state.storyboards = storyboards
                LoggingService.shared.logPlayer("Loaded \(storyboards.count) storyboards (deferred), preferred: \(state.preferredStoryboard?.width ?? 0)x\(state.preferredStoryboard?.height ?? 0)")
            }

            // The freshly fetched streams have new (signed) URLs that won't
            // match `state.currentStream`/`state.currentAudioStream` from the
            // queue's pre-resolved entry — repoint state to the equivalent
            // refreshed stream (matched by Stream.id which is
            // resolution+fps+format) so the picker shows checkmarks on the
            // currently playing tracks.
            if let playing = state.currentStream,
               !playing.url.isFileURL,
               let refreshed = combined.first(where: { $0.id == playing.id })
            {
                state.updateCurrentStream(refreshed)
            }
            if let playingAudio = state.currentAudioStream,
               !playingAudio.url.isFileURL,
               let refreshedAudio = combined.first(where: { $0.id == playingAudio.id })
            {
                state.updateCurrentAudioStream(refreshedAudio)
            }
            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 }

        // Picking a video stream while audio mode is on turns the mode off
        // so the explicit choice sticks (same rule as selectStreamManually)
        if !stream.isAudioOnly, settingsManager?.audioOnlyModeEnabled == true {
            settingsManager?.audioOnlyModeEnabled = false
            LoggingService.shared.logPlayer("Audio mode disabled by explicit quality selection")
        }

        // 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)
    }

    /// Applies audio-only mode to local (downloaded) streams. Online streams
    /// are handled by stream selection; local files reach `play()` pre-resolved,
    /// so the swap happens here instead.
    private func applyingAudioModeToLocalStreams(video: Video, stream: Stream, audioStream: Stream?) -> (Stream, Stream?) {
        guard stream.url.isFileURL else { return (stream, audioStream) }

        if settingsManager?.audioOnlyModeEnabled == true {
            guard !stream.isAudioOnly else { return (stream, audioStream) }
            if let audioStream, audioStream.url.isFileURL {
                // Separate downloaded audio track - play it on its own
                LoggingService.shared.logPlayer("Audio mode: using downloaded audio track for \(video.id.id)")
                return (audioStream, nil)
            }
            // Muxed file - same URL with the video track disabled at load time
            LoggingService.shared.logPlayer("Audio mode: playing muxed local file without video track for \(video.id.id)")
            return (stream.audioOnlyVariant(), nil)
        }

        // Mode is off but an audio-only stream was stored for a downloaded
        // video (e.g. queue/history item created while audio mode was on) -
        // restore the full local streams from the download record.
        if stream.isAudioOnly,
           let downloadManager,
           let download = downloadManager.download(for: video.id),
           download.status == .completed,
           let (_, videoStream, downloadAudio, _, _) = downloadManager.videoAndStream(for: download) {
            return (videoStream, downloadAudio)
        }
        return (stream, audioStream)
    }

    /// Toggles global audio-only ("music") mode. If an online video is playing,
    /// reloads it at the current position (same mechanism as a quality switch).
    func setAudioMode(_ enabled: Bool) async {
        guard settingsManager?.audioOnlyModeEnabled != enabled else { return }
        settingsManager?.audioOnlyModeEnabled = enabled
        LoggingService.shared.logPlayer("Audio mode \(enabled ? "enabled" : "disabled")")

        // Nothing playing: just persist the setting.
        guard state.currentVideo != nil else { return }

        // Downloaded content: re-derive the local streams from the download
        // record and reload; play() applies the mode swap for local files
        // (separate audio track, or same file with the video track disabled).
        if let currentDownload {
            guard (state.currentStream?.isAudioOnly == true) != enabled,
                  let downloadManager,
                  let (downloadedVideo, localStream, localAudio, captionURL, _) = downloadManager.videoAndStream(for: currentDownload)
            else { return }
            let currentTime = state.currentTime
            await play(video: downloadedVideo, stream: localStream, audioStream: localAudio, startTime: currentTime)
            if let captionURL {
                loadLocalCaption(url: captionURL)
            }
            return
        }

        // No stream list to reselect from (e.g. still loading): just persist.
        guard let video = state.currentVideo, !availableStreams.isEmpty else { return }

        let currentTime = state.currentTime

        if enabled {
            // Already audio-only (e.g. audio-only content) - nothing to reload
            guard state.currentStream?.isAudioOnly != true else { return }
            guard let bestAudio = bestAudioStream(from: availableStreams.filter { $0.isAudioOnly }) else {
                LoggingService.shared.logPlayer("Audio mode: no audio-only streams for current video, keeping current stream")
                return
            }
            await play(video: video, stream: bestAudio, audioStream: nil, startTime: currentTime)
        } else {
            // Re-run normal selection with the mode now off
            let selection = selectStreamAndBackend(from: availableStreams)
            guard let stream = selection.stream else { return }
            // Audio-only content still selects audio - no reload needed
            if stream.isAudioOnly, state.currentStream?.isAudioOnly == true { return }
            await play(video: video, stream: stream, audioStream: selection.audioStream, startTime: currentTime)
        }
    }

    /// Handles an explicit stream pick from the quality selector, switching at
    /// the current playback position. Picking a video stream while audio mode
    /// is on turns audio mode off so the choice sticks.
    func selectStreamManually(_ stream: Stream, audioStream: Stream?) async {
        guard let video = state.currentVideo else { return }

        if !stream.isAudioOnly, settingsManager?.audioOnlyModeEnabled == true {
            settingsManager?.audioOnlyModeEnabled = false
            LoggingService.shared.logPlayer("Audio mode disabled by explicit quality selection")
        }

        let currentTime = state.currentTime
        LoggingService.shared.logPlayer("Manual stream switch to \(stream.qualityLabel) at \(currentTime)s")
        await play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime)
    }

    // MARK: - Media Browser Stream Resolution

    /// Resolves stream and captions for a media browser video on-demand.
    /// - Parameters:
    ///   - video: The video to resolve
    ///   - context: Media browser context with source and folder files
    /// - Returns: Tuple of (stream, captions) ready for playback
    func resolveMediaBrowserStream(
        for video: Video,
        context: MediaBrowserQueueContext
    ) async throws -> (stream: Stream, captions: [Caption]) {
        // Find the MediaFile for this video in the folder
        // videoID format: "sourceUUID:path"
        guard let mediaFile = context.allFilesInFolder.first(where: { $0.id == video.id.videoID }) else {
            throw MediaSourceError.pathNotFound("Video file not found in folder context")
        }

        // Find matching subtitle files
        let subtitleFiles = mediaFile.findMatchingSubtitles(in: context.allFilesInFolder)

        let source = context.source
        let password = mediaSourcesManager?.password(for: source)

        switch source.type {
        case .webdav:
            // Get auth headers for WebDAV
            var authHeaders: [String: String]?
            if let webDAVClient {
                authHeaders = await webDAVClient.authHeaders(for: source, password: password)
            }
            let stream = mediaFile.toStream(authHeaders: authHeaders)
            // Subtitles can use remote URLs directly for WebDAV
            return (stream, subtitleFiles)

        case .smb:
            guard let smbClient else {
                throw MediaSourceError.unknown("SMB client not available")
            }

            let folderKey = source.id.uuidString + ":" + context.folderPath

            // Pre-download ALL subtitles in folder on first access.
            // Must complete before MPV opens any SMB connection.
            // For queued videos from the same folder, subtitles will already be cached.
            if !preDownloadedSubtitleFolders.contains(folderKey) {
                let allSubtitleFiles = context.allFilesInFolder.filter { $0.isSubtitle }
                LoggingService.shared.logMediaSources("Pre-downloading \(allSubtitleFiles.count) subtitle(s) from folder: \(context.folderPath)")

                for subtitleFile in allSubtitleFiles {
                    do {
                        let localURL = try await smbClient.downloadSubtitleToTemp(
                            file: subtitleFile,
                            source: source,
                            password: password,
                            videoID: folderKey
                        )
                        downloadedSubtitlesCache[subtitleFile.id] = localURL
                        LoggingService.shared.logMediaSources("Pre-downloaded subtitle: \(subtitleFile.name) → \(localURL.lastPathComponent)")
                    } catch {
                        LoggingService.shared.error(
                            "Failed to pre-download subtitle \(subtitleFile.name): \(error.localizedDescription)",
                            category: .general
                        )
                    }
                }

                preDownloadedSubtitleFolders.insert(folderKey)

                // Release app SMB context before MPV opens its connection.
                // libsmbclient uses process-global talloc state that corrupts
                // when two contexts access the same server concurrently.
                await smbClient.clearCache(for: source)
                LoggingService.shared.logMediaSources("Cleared SMB context cache before playback")
            }

            // Construct video playback URL (pure string manipulation, no libsmbclient)
            let playbackURL = try await smbClient.constructPlaybackURL(
                for: mediaFile,
                source: source,
                password: password
            )
            let stream = Stream(url: playbackURL, resolution: nil, format: mediaFile.fileExtension)

            // Build captions from pre-downloaded local files
            var localCaptions: [Caption] = []
            for subtitle in subtitleFiles {
                if let subtitleFile = context.allFilesInFolder.first(where: { $0.url == subtitle.url }),
                   let localURL = downloadedSubtitlesCache[subtitleFile.id] {
                    localCaptions.append(Caption(
                        label: subtitle.label,
                        languageCode: subtitle.languageCode,
                        url: localURL
                    ))
                    LoggingService.shared.info(
                        "Using pre-downloaded subtitle: \(subtitleFile.name)",
                        category: .general
                    )
                }
            }

            return (stream, localCaptions)

        case .localFolder:
            // Local folder - no auth needed, use file:// URLs directly
            let stream = mediaFile.toStream(authHeaders: nil)
            return (stream, subtitleFiles)
        }
    }

    /// Resolves stream and subtitles for a media source video by fetching folder contents on-demand.
    /// Works for all media source types (WebDAV, SMB, local folders) from any playback source
    /// (Media Browser, Continue Watching, etc.).
    func resolveMediaSourceStream(for video: Video) async throws -> (stream: Stream, captions: [Caption]) {
        LoggingService.shared.debug("[SubtitleDebug] resolveMediaSourceStream called for videoID: \(video.id.videoID)", category: .player)

        // 1. Extract source ID and file path from video ID
        guard let sourceID = video.mediaSourceID,
              let filePath = video.mediaSourceFilePath else {
            LoggingService.shared.error("[SubtitleDebug] Failed to extract source info - mediaSourceID: \(String(describing: video.mediaSourceID)), mediaSourceFilePath: \(String(describing: video.mediaSourceFilePath))", category: .player)
            throw MediaSourceError.pathNotFound("Could not extract source info from video ID")
        }

        LoggingService.shared.debug("[SubtitleDebug] Extracted sourceID: \(sourceID), filePath: \(filePath)", category: .player)

        // 2. Look up the MediaSource
        guard let source = mediaSourcesManager?.source(byID: sourceID) else {
            LoggingService.shared.error("[SubtitleDebug] Media source not found for ID: \(sourceID)", category: .player)
            throw MediaSourceError.unknown("Media source not found")
        }

        LoggingService.shared.debug("[SubtitleDebug] Found source: \(source.name), type: \(source.type)", category: .player)

        let password = mediaSourcesManager?.password(for: source)

        // Legacy video IDs for `.localFolder` sources may carry an absolute container
        // path (recorded before relative paths were computed correctly). Normalize
        // against the source folder name so the path becomes relative and doesn't
        // get appended onto the freshly resolved bookmark base, which would produce
        // a doubled `/Documents/<folder>/private/var/.../Documents/<folder>/...` path.
        let normalizedFilePath: String
        if source.type == .localFolder {
            normalizedFilePath = LocalFileClient.normalizeAbsolutePath(filePath, sourceFolderName: source.url.lastPathComponent)
        } else {
            normalizedFilePath = filePath
        }
        let parentPath = (normalizedFilePath as NSString).deletingLastPathComponent
        let fileName = (normalizedFilePath as NSString).lastPathComponent

        LoggingService.shared.debug("[SubtitleDebug] parentPath: \(parentPath), fileName: \(fileName)", category: .player)

        // 3. Fetch folder contents based on source type (with cache for queued playback)
        let cacheKey = "\(sourceID):\(parentPath)"
        let folderFiles: [MediaFile]

        if let cached = folderFilesCache[cacheKey] {
            LoggingService.shared.debug("[SubtitleDebug] Using cached folder listing for \(cacheKey) (\(cached.count) files)", category: .player)
            folderFiles = cached
        } else {
            switch source.type {
            case .webdav:
                guard let webDAVClient else {
                    throw MediaSourceError.unknown("WebDAV client not available")
                }
                folderFiles = try await webDAVClient.listFiles(at: parentPath, source: source, password: password)

            case .smb:
                guard let smbClient else {
                    throw MediaSourceError.unknown("SMB client not available")
                }
                folderFiles = try await smbClient.listFiles(at: parentPath, source: source, password: password)

            case .localFolder:
                guard let localFileClient else {
                    throw MediaSourceError.unknown("Local file client not available")
                }
                folderFiles = try await localFileClient.listFiles(at: parentPath, source: source)
            }
            folderFilesCache[cacheKey] = folderFiles
            LoggingService.shared.debug("[SubtitleDebug] Listed \(folderFiles.count) files in folder, cached as \(cacheKey)", category: .player)
        }

        // 4. Find the MediaFile for this video
        guard let mediaFile = folderFiles.first(where: { $0.name == fileName }) else {
            throw MediaSourceError.pathNotFound("Video file not found in folder")
        }

        // 5. Find matching subtitles
        let subtitleCaptions = mediaFile.findMatchingSubtitles(in: folderFiles)

        // 6. Build stream and process captions based on source type
        switch source.type {
        case .webdav:
            // Get auth headers for WebDAV
            var authHeaders: [String: String]?
            if let webDAVClient {
                authHeaders = await webDAVClient.authHeaders(for: source, password: password)
            }
            let stream = mediaFile.toStream(authHeaders: authHeaders)
            // Subtitles can use remote URLs directly for WebDAV
            return (stream, subtitleCaptions)

        case .smb:
            guard let smbClient else {
                throw MediaSourceError.unknown("SMB client not available")
            }

            let folderKey = "\(sourceID):\(parentPath)"

            // Pre-download ALL subtitles in folder on first access.
            // Must complete before MPV opens any SMB connection.
            // For queued videos from the same folder, subtitles will already be cached.
            if !preDownloadedSubtitleFolders.contains(folderKey) {
                let allSubtitleFiles = folderFiles.filter { $0.isSubtitle }
                LoggingService.shared.logMediaSources("Pre-downloading \(allSubtitleFiles.count) subtitle(s) from folder: \(parentPath)")

                for subtitleFile in allSubtitleFiles {
                    do {
                        let localURL = try await smbClient.downloadSubtitleToTemp(
                            file: subtitleFile,
                            source: source,
                            password: password,
                            videoID: folderKey
                        )
                        downloadedSubtitlesCache[subtitleFile.id] = localURL
                        LoggingService.shared.logMediaSources("Pre-downloaded subtitle: \(subtitleFile.name) → \(localURL.lastPathComponent)")
                    } catch {
                        LoggingService.shared.error(
                            "Failed to pre-download subtitle \(subtitleFile.name): \(error.localizedDescription)",
                            category: .general
                        )
                    }
                }

                preDownloadedSubtitleFolders.insert(folderKey)

                // Release app SMB context before MPV opens its connection.
                // libsmbclient uses process-global talloc state that corrupts
                // when two contexts access the same server concurrently.
                await smbClient.clearCache(for: source)
                LoggingService.shared.logMediaSources("Cleared SMB context cache before playback")
            }

            // Construct video playback URL (pure string manipulation, no libsmbclient)
            let playbackURL = try await smbClient.constructPlaybackURL(
                for: mediaFile,
                source: source,
                password: password
            )
            let stream = Stream(url: playbackURL, resolution: nil, format: mediaFile.fileExtension)

            // Build captions from pre-downloaded local files
            var localCaptions: [Caption] = []
            for subtitle in subtitleCaptions {
                if let subtitleFile = folderFiles.first(where: { $0.url == subtitle.url }),
                   let localURL = downloadedSubtitlesCache[subtitleFile.id] {
                    localCaptions.append(Caption(
                        label: subtitle.label,
                        languageCode: subtitle.languageCode,
                        url: localURL
                    ))
                    LoggingService.shared.info(
                        "Using pre-downloaded subtitle: \(subtitleFile.name)",
                        category: .general
                    )
                }
            }

            return (stream, localCaptions)

        case .localFolder:
            // Local folder - no auth needed, use file:// URLs directly
            let stream = mediaFile.toStream(authHeaders: nil)
            return (stream, subtitleCaptions)
        }
    }

    // MARK: - Private Methods

    /// Checks if the stream URL goes through Yattee Server's fast download endpoint.
    /// Fast endpoint streams don't have Content-Length, so MPV can't determine accurate duration.
    /// In this case, we should use the API-provided duration instead.
    private func isFastEndpointStream(_ stream: Stream?) -> Bool {
        guard let urlString = stream?.url.absoluteString else { return false }
        return urlString.contains("/proxy/fast/")
    }

    /// Locks duration from API if playing through fast endpoint.
    /// This prevents progress bar jitter when MPV reports changing duration during progressive download.
    private func lockDurationIfNeeded(for video: Video, stream: Stream?) {
        if isFastEndpointStream(stream), video.duration > 0 {
            state.lockDuration(video.duration)
            LoggingService.shared.debug("Locked duration from API: \(video.duration)s (fast endpoint stream)", category: .player)
        }
    }

    private func setupAudioSession() {
        #if os(iOS) || os(tvOS)
        do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.playback, mode: .moviePlayback)
            try session.setActive(true)

            #if os(tvOS)
            nowPlayingService.refreshSystemControlsForCurrentAudioRoute(reason: "audio session activation")
            #endif

            // Only register observer once
            guard !hasRegisteredInterruptionObserver else { return }
            hasRegisteredInterruptionObserver = true

            LoggingService.shared.debug("Registering audio session interruption observer", category: .player)

            // Register for audio interruption notifications (phone calls, alarms, etc.)
            NotificationCenter.default.addObserver(
                forName: AVAudioSession.interruptionNotification,
                object: session,
                queue: .main
            ) { [weak self] notification in
                // Extract values before async boundary to satisfy Sendable requirements
                guard let userInfo = notification.userInfo,
                      let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
                      let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
                    return
                }
                let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt

                Task { @MainActor [weak self] in
                    LoggingService.shared.debug("Audio interruption notification received, type: \(type.rawValue), options: \(optionsValue ?? 0)", category: .player)
                    self?.handleAudioSessionInterruption(type: type, optionsValue: optionsValue)
                }
            }
        } catch {
            LoggingService.shared.logPlayerError("Audio session setup failed", error: error)
        }
        #endif
    }

    #if os(iOS) || os(tvOS)
    private func handleAudioSessionInterruption(type: AVAudioSession.InterruptionType, optionsValue: UInt?) {
        LoggingService.shared.debug("handleAudioSessionInterruption called, current playbackState: \(state.playbackState), wasInterrupted: \(wasInterrupted)", category: .player)

        switch type {
        case .began:
            LoggingService.shared.debug("Audio session interrupted (began), playbackState: \(state.playbackState)", category: .player)
            // Audio was interrupted (phone call, alarm, etc.)
            if state.playbackState == .playing {
                LoggingService.shared.debug("Pausing playback due to interruption", category: .player)
                // Call full pause() to properly pause the backend
                pause()
                wasInterrupted = true
            } else {
                LoggingService.shared.debug("Not pausing - playbackState is not .playing", category: .player)
            }
        case .ended:
            LoggingService.shared.debug("Audio session interruption ended, wasInterrupted: \(wasInterrupted)", category: .player)
            // Interruption ended - check if we should resume
            if let optionsValue {
                let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
                LoggingService.shared.debug("Interruption options: shouldResume=\(options.contains(.shouldResume))", category: .player)
                if options.contains(.shouldResume) && wasInterrupted {
                    LoggingService.shared.debug("Auto-resuming playback after interruption", category: .player)
                    // Reactivate audio session before resuming - iOS requires this after interruption
                    do {
                        try AVAudioSession.sharedInstance().setActive(true)
                        LoggingService.shared.debug("Audio session reactivated successfully", category: .player)
                    } catch {
                        LoggingService.shared.error("Failed to reactivate audio session: \(error.localizedDescription)", category: .player)
                    }
                    resume()
                } else {
                    LoggingService.shared.debug("Not auto-resuming: shouldResume=\(options.contains(.shouldResume)), wasInterrupted=\(wasInterrupted)", category: .player)
                }
            } else {
                LoggingService.shared.debug("No interruption options provided", category: .player)
            }
            wasInterrupted = false
        @unknown default:
            LoggingService.shared.debug("Unknown interruption type: \(type.rawValue)", category: .player)
            break
        }
    }
    #endif

    private func fetchVideoStreamsAndCaptions(for video: Video) async throws -> (Video, [Stream], [Caption]) {
        let result = try await fetchVideoStreamsAndCaptionsAndStoryboards(for: video)
        return (result.0, result.1, result.2)
    }

    private func fetchVideoStreamsAndCaptionsAndStoryboards(for video: Video) async throws -> (Video, [Stream], [Caption], [Storyboard]) {
        // Handle media source videos (WebDAV, SMB, and local folders)
        // These don't need API calls - we reconstruct the stream from the stored URL
        if case .extracted(let extractor, let originalURL) = video.id.source {
            if extractor == MediaFile.webdavProvider
                || extractor == MediaFile.localFolderProvider
                || extractor == MediaFile.smbProvider {
                let stream = try await createStreamForMediaSource(video: video, url: originalURL, extractor: extractor)
                return (video, [stream], [], [])
            }
        }

        guard let instance = try await findInstance(for: video) else {
            throw APIError.noInstance
        }

        // For extracted videos, use the extract endpoint with the original URL
        // (stream URLs expire, so we need to re-extract each time)
        if case .extracted(_, let originalURL) = video.id.source {
            guard instance.type == .yatteeServer else {
                throw APIError.notSupported
            }
            let result = try await contentService.extractURL(originalURL, instance: instance)
            return (result.video, result.streams, result.captions, [])
        }

        // Race the proxy-detection HEAD against the video API call so the
        // verdict is (usually) ready by the time streams come back. Cheap
        // when there's nothing to do — it returns immediately if the
        // verdict is already cached, or if no prior CDN sample exists.
        async let _: Void = InvidiousAPI.prewarmProxyDetection(for: instance)

        // Fetch full video details, streams, captions, and storyboards in a single API call
        // (for Invidious, this is a single request; for other backends, calls are made in parallel)
        let result = try await contentService.videoWithStreamsAndCaptionsAndStoryboards(id: video.id.videoID, instance: instance)

        // Store original (unproxied) streams so we can re-apply proxy when settings change
        self.originalStreams = result.streams

        // Apply proxy URL rewriting for instances that support it
        let streams = await InvidiousAPI.proxyStreamsIfNeeded(result.streams, instance: instance)

        return (result.video, streams, result.captions, result.storyboards)
    }

    /// Re-applies proxy URL rewriting to cached streams when instance settings change.
    /// Uses the stored `originalStreams` to derive the correct URLs without re-fetching from the API.
    private func reapplyProxyToStreams() async {
        guard !originalStreams.isEmpty,
              let video = state.currentVideo,
              let instance = try? await findInstance(for: video) else { return }

        let newStreams = await InvidiousAPI.proxyStreamsIfNeeded(originalStreams, instance: instance)

        // Update available streams (preserve any downloaded/local file streams)
        let downloadedStreams = availableStreams.filter { $0.url.isFileURL }
        availableStreams = downloadedStreams + newStreams

        // Update current stream URL if it changed
        if let currentStream = state.currentStream,
           let matchingNew = newStreams.first(where: { $0.resolution == currentStream.resolution && $0.format == currentStream.format }) {
            if matchingNew.url != currentStream.url {
                state.updateCurrentStream(matchingNew)
                LoggingService.shared.logPlayer("Updated current stream URL after proxy setting change")
            }
        }

        // Update current audio stream URL if it changed
        if let currentAudio = state.currentAudioStream,
           let matchingNew = newStreams.first(where: { $0.resolution == currentAudio.resolution && $0.format == currentAudio.format && $0.isAudioOnly == true }) {
            if matchingNew.url != currentAudio.url {
                state.updateCurrentAudioStream(matchingNew)
                LoggingService.shared.logPlayer("Updated current audio stream URL after proxy setting change")
            }
        }

        LoggingService.shared.logPlayer("Re-applied proxy settings: \(newStreams.count) streams updated")
    }

    /// Creates a stream for media source videos (WebDAV, SMB, or local folder).
    /// For WebDAV, adds authentication headers. For SMB, constructs URL with embedded credentials.
    private func createStreamForMediaSource(video: Video, url: URL, extractor: String) async throws -> Stream {
        // Parse the MediaSource UUID from the video ID
        // Format: "UUID:/path/to/file.mp4"
        let videoID = video.id.videoID
        var headers: [String: String]? = nil
        var playbackURL = url

        if let separatorIndex = videoID.firstIndex(of: ":"),
           let sourceUUID = UUID(uuidString: String(videoID[..<separatorIndex])) {
            // For WebDAV sources, add authentication headers
            if extractor == MediaFile.webdavProvider {
                if let source = mediaSourcesManager?.sources.first(where: { $0.id == sourceUUID }) {
                    if let username = source.username,
                       let password = mediaSourcesManager?.password(for: source) {
                        let credentials = "\(username):\(password)"
                        if let credentialsData = credentials.data(using: .utf8) {
                            let base64Credentials = credentialsData.base64EncodedString()
                            headers = ["Authorization": "Basic \(base64Credentials)"]
                            LoggingService.shared.logPlayer("Media source: Created WebDAV stream with auth for '\(source.name)'")
                        }
                    } else {
                        LoggingService.shared.logPlayer("Media source: Created WebDAV stream without auth (no credentials) for '\(source.name)'")
                    }
                } else {
                    LoggingService.shared.logPlayer("Media source: WebDAV source not found for UUID \(sourceUUID)")
                }
            }
            // For SMB sources, construct URL with embedded credentials
            else if extractor == MediaFile.smbProvider {
                if let source = mediaSourcesManager?.sources.first(where: { $0.id == sourceUUID }) {
                    // Reconstruct MediaFile from video to use SMBClient
                    let path = String(videoID.dropFirst(sourceUUID.uuidString.count + 1))
                    let mediaFile = MediaFile(
                        source: source,
                        path: path,
                        name: url.lastPathComponent,
                        isDirectory: false
                    )

                    let password = mediaSourcesManager?.password(for: source)
                    if let smbClient {
                        playbackURL = try await smbClient.constructPlaybackURL(
                            for: mediaFile,
                            source: source,
                            password: password
                        )
                    } else {
                        LoggingService.shared.logPlayer("Media source: SMBClient not available, using original URL")
                    }
                    LoggingService.shared.logPlayer("Media source: Created SMB stream with embedded auth for '\(source.name)' - URL: \(playbackURL.sanitized)")
                } else {
                    LoggingService.shared.logPlayer("Media source: SMB source not found for UUID \(sourceUUID)")
                }
            }
            // Local folder
            else {
                LoggingService.shared.logPlayer("Media source: Created local folder stream")
            }
        } else {
            LoggingService.shared.logPlayer("Media source: Could not parse UUID from videoID \(videoID)")
        }

        // Create stream with placeholder resolution and audio codec so it's recognized as muxed
        // (actual values are unknown until playback, but MPV handles all formats)
        return Stream(
            url: playbackURL,
            resolution: .p720,  // Placeholder - actual resolution unknown
            format: url.pathExtension,
            videoCodec: "avc1",  // Placeholder to indicate video content
            audioCodec: "aac",   // Placeholder to mark as muxed (has audio)
            httpHeaders: headers
        )
    }

    private func selectStreamAndBackend(from streams: [Stream]) -> (stream: Stream?, audioStream: Stream?, backend: PlayerBackendType) {
        // Try preferred backend first
        if let (stream, audioStream) = selectStreams(for: preferredBackendType, from: streams) {
            return (stream, audioStream, preferredBackendType)
        }

        // Fallback to any available backend
        for backendType in backendFactory.availableBackends {
            if let (stream, audioStream) = selectStreams(for: backendType, from: streams) {
                return (stream, audioStream, backendType)
            }
        }

        return (nil, nil, preferredBackendType)
    }

    /// Selects best streams for preloading (exposed for QueueManager).
    func selectStreamsForPreload(from streams: [Stream]) -> (stream: Stream?, audioStream: Stream?) {
        let result = selectStreamAndBackend(from: streams)
        return (result.stream, result.audioStream)
    }

    private func selectStreams(for backendType: PlayerBackendType, from streams: [Stream]) -> (video: Stream, audio: Stream?)? {
        let supportedFormats = backendType.supportedFormats

        // 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 }
            // 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 }

        // Audio-only ("music") mode: skip the video track entirely
        if settingsManager?.audioOnlyModeEnabled == true {
            if let bestAudio = bestAudioStream(from: audioStreams) {
                LoggingService.shared.debug("Stream selection: Audio mode enabled, selecting audio-only stream", category: .player)
                return (bestAudio, nil)
            }
            LoggingService.shared.warning("Stream selection: Audio mode enabled but no audio-only streams available, falling back to normal selection", category: .player)
        }

        // 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, let bestAudio = bestAudioStream(from: audioStreams) {
            LoggingService.shared.debug("Stream selection: Audio-only content detected, using best audio stream", category: .player)
            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 let dashStream = muxedStreams.first(where: { StreamFormat.detect(from: $0) == .dash }) {
                LoggingService.shared.debug("Stream selection: Using DASH for live stream", category: .player)
                return (dashStream, nil)
            }
            LoggingService.shared.warning("Stream selection: No HLS/DASH available for live stream, falling back to adaptive formats", category: .player)
        }

        // Note: For non-live videos, we prefer progressive formats (MP4/WebM) over HLS/DASH
        // because they typically offer better quality. HLS/DASH are only used as last resort.
        // Live streams are already handled above with HLS/DASH preference.

        // Try to find the best video-only stream + audio (for MPV which supports all formats)
        if backendType == .mpv && !videoOnlyStreams.isEmpty && !audioStreams.isEmpty {
            let filteredVideoStreams: [Stream]
            if let maxRes = maxResolution {
                filteredVideoStreams = videoOnlyStreams.filter { stream in
                    guard let resolution = stream.resolution else { return true }
                    return resolution <= maxRes
                }
            } else {
                filteredVideoStreams = videoOnlyStreams
            }

            // Filter out codecs with priority 0 (software decode) if hardware options exist,
            // unless the user opted in to software-decoded formats.
            let streamsToConsider: [Stream]
            if settingsManager?.allowSoftwareDecodedFormats ?? false {
                streamsToConsider = filteredVideoStreams
            } else {
                let hardwareDecodableStreams = filteredVideoStreams.filter { videoCodecPriority($0.videoCodec) > 0 }
                streamsToConsider = hardwareDecodableStreams.isEmpty ? filteredVideoStreams : hardwareDecodableStreams
            }

            // Sort by resolution first, then by codec priority
            let sortedVideo = streamsToConsider.sorted { s1, s2 in
                let res1 = s1.resolution ?? .p360
                let res2 = s2.resolution ?? .p360
                if res1 != res2 {
                    return res1 > res2
                }
                // Same resolution - prefer better codec
                return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec)
            }

            if let bestVideo = sortedVideo.first, let audio = bestAudioStream(from: audioStreams) {
                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 progressive formats, then HLS, and only fall back to DASH as a last
        // resort (DASH should never be chosen when any other format is available).
        let sortedMuxed = filteredMuxed.sorted { s1, s2 in
            let rank1 = muxedFormatRank(StreamFormat.detect(from: s1))
            let rank2 = muxedFormatRank(StreamFormat.detect(from: s2))
            if rank1 != rank2 {
                return rank1 < rank2
            }
            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
    }

    /// Ranks muxed formats for fallback selection (lower = preferred).
    /// Progressive formats win, then HLS; DASH is the last resort and is only
    /// selected when it is the only format available.
    private func muxedFormatRank(_ format: StreamFormat) -> Int {
        switch format {
        case .dash: return 2
        case .hls: return 1
        default: return 0 // progressive
        }
    }

    /// 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)
    }

    /// Selects the best audio-only stream: preferred language (or original audio
    /// when no preference is set), then codec priority, then bitrate.
    private func bestAudioStream(from audioStreams: [Stream]) -> Stream? {
        let preferredAudioLanguage = settingsManager?.preferredAudioLanguage
        return audioStreams
            .sorted { stream1, stream2 in
                if let preferred = preferredAudioLanguage {
                    // User selected a specific language
                    let matches1 = (stream1.audioLanguage ?? "").hasPrefix(preferred)
                    let matches2 = (stream2.audioLanguage ?? "").hasPrefix(preferred)
                    if matches1 != matches2 { return matches1 }
                } else {
                    // No preference set - prefer original audio track
                    if stream1.isOriginalAudio != stream2.isOriginalAudio {
                        return stream1.isOriginalAudio
                    }
                }

                let codecPriority1 = audioCodecPriority(stream1.audioCodec)
                let codecPriority2 = audioCodecPriority(stream2.audioCodec)
                if codecPriority1 != codecPriority2 {
                    return codecPriority1 > codecPriority2
                }

                return (stream1.bitrate ?? 0) > (stream2.bitrate ?? 0)
            }
            .first
    }

    /// 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 os(macOS)
                    // On macOS, always restore into the expanded player window —
                    // the mini player is not a restore target there
                    coordinator?.expandPlayer()
                    #else
                    // If mini player video is disabled, expand player for restore
                    // Otherwise video continues in mini player
                    if MiniPlayerSettings.cached.showVideo == false {
                        coordinator?.expandPlayer()
                    }
                    #endif
                }
                mpvBackend.onPiPDidStart = { [weak coordinator] in
                    guard let coordinator else {
                        LoggingService.shared.debug("PlayerService: onPiPDidStart - coordinator is nil", category: .player)
                        return
                    }
                    // Collapse the player sheet/window when PiP starts
                    LoggingService.shared.debug("PlayerService: onPiPDidStart - isPlayerExpanded=\(coordinator.isPlayerExpanded)", category: .player)
                    if coordinator.isPlayerExpanded {
                        // Set collapsing first so mini player shows video immediately
                        coordinator.isPlayerCollapsing = true
                        coordinator.isPlayerExpanded = false
                        LoggingService.shared.debug("PlayerService: onPiPDidStart - set isPlayerExpanded to false", category: .player)
                    }
                }
                #if os(macOS)
                // Clean up hidden window when PiP is closed via X button (not restore)
                mpvBackend.onPiPDidStopWithoutRestore = {
                    LoggingService.shared.debug("PlayerService: onPiPDidStopWithoutRestore - cleaning up hidden window", category: .player)
                    ExpandedPlayerWindowManager.shared.cleanupAfterPiP()
                }
                #endif
            }
        }
        #endif

        return backend
    }

    private var instancesManager: InstancesManager?
    private weak var mediaSourcesManager: MediaSourcesManager?
    private var smbClient: SMBClient?
    private var webDAVClient: WebDAVClient?
    private var localFileClient: LocalFileClient?
    private var folderFilesCache: [String: [MediaFile]] = [:]

    /// Cache of pre-downloaded subtitle local file URLs.
    /// Key: subtitle MediaFile.id, Value: local temp URL
    private var downloadedSubtitlesCache: [String: URL] = [:]

    /// Tracks folders whose subtitles have been pre-downloaded.
    /// Prevents re-downloading when resolving queued videos from the same folder.
    private var preDownloadedSubtitleFolders: Set<String> = []

    /// Sets the instances manager for finding instances.
    func setInstancesManager(_ manager: InstancesManager) {
        self.instancesManager = manager

        // Observe instance setting changes (e.g. proxy toggle) to re-apply proxy to cached streams
        instanceChangeObserver = NotificationCenter.default.addObserver(
            forName: .instancesDidChange,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            guard let self else { return }
            Task { @MainActor [weak self] in
                await self?.reapplyProxyToStreams()
            }
        }
    }

    /// Sets the media sources manager for WebDAV/SMB/local folder playback.
    func setMediaSourcesManager(_ manager: MediaSourcesManager) {
        self.mediaSourcesManager = manager
    }

    /// Sets the SMB client for SMB playback.
    func setSMBClient(_ client: SMBClient) {
        self.smbClient = client
    }

    /// Sets the WebDAV client for WebDAV playback.
    func setWebDAVClient(_ client: WebDAVClient) {
        self.webDAVClient = client
    }

    /// Sets the local file client for local folder playback.
    func setLocalFileClient(_ client: LocalFileClient) {
        self.localFileClient = client
    }

    /// Sets the settings manager for accessing user preferences.
    func setSettingsManager(_ manager: SettingsManager) {
        self.settingsManager = manager
        self.backendSwitcher.settingsManager = manager
        self.nowPlayingService.settingsManager = manager
        // Reconfigure remote commands now that we have settings
        nowPlayingService.configureRemoteCommands()
        // Note: preferredBackendType is now computed directly from settingsManager
    }

    /// Reconfigures system control buttons (Control Center, Lock Screen) based on current settings.
    /// Call this when system controls settings change.
    func reconfigureSystemControls(
        mode: SystemControlsMode? = nil,
        duration: SystemControlsSeekDuration? = nil
    ) {
        nowPlayingService.configureRemoteCommands(mode: mode, duration: duration)
    }

    /// Observer for instance setting changes to re-apply proxy to cached streams.
    private var instanceChangeObserver: NSObjectProtocol?

    /// Observer for preset changes to reconfigure system controls.
    private var presetChangeObserver: NSObjectProtocol?

    /// Sets the player controls layout service for reading preset-specific settings.
    func setPlayerControlsLayoutService(_ service: PlayerControlsLayoutService) {
        self.playerControlsLayoutService = service
        nowPlayingService.playerControlsLayoutService = service

        // Reconfigure with the actual saved settings now that layout service is available
        nowPlayingService.configureRemoteCommands()

        // Observe preset changes to reconfigure system controls
        presetChangeObserver = NotificationCenter.default.addObserver(
            forName: .playerControlsActivePresetDidChange,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            guard let self else { return }
            Task { @MainActor [weak self] in
                self?.reconfigureSystemControls()
            }
        }
    }

    /// Sets the download manager for checking downloaded videos.
    func setDownloadManager(_ manager: DownloadManager) {
        self.downloadManager = manager
    }

    /// Sets the DeArrow branding provider for enhanced titles.
    func setDeArrowBrandingProvider(_ provider: DeArrowBrandingProvider) {
        nowPlayingService.deArrowBrandingProvider = provider
    }

    /// Sets the connectivity monitor for network-aware quality selection.
    func setConnectivityMonitor(_ monitor: ConnectivityMonitor) {
        self.connectivityMonitor = monitor
    }

    /// Sets the toast manager for displaying notifications.
    func setToastManager(_ manager: ToastManager) {
        self.toastManager = manager
    }

    /// Sets the queue manager for queue operations.
    func setQueueManager(_ manager: QueueManager) {
        self.queueManager = manager
    }

    /// Sets the navigation coordinator for waiting on sheet animations.
    func setNavigationCoordinator(_ coordinator: NavigationCoordinator) {
        self.navigationCoordinator = coordinator

        #if os(iOS)
        // Configure PiP restore callback - don't expand player, just let PiP close
        // Video continues in mini player; user taps mini player to expand
        if let mpvBackend = currentBackend as? MPVBackend {
            mpvBackend.onRestoreFromPiP = { [weak coordinator] in
                // If mini player video is disabled, expand player for restore
                // Otherwise video continues in mini player
                if MiniPlayerSettings.cached.showVideo == false {
                    coordinator?.expandPlayer()
                }
            }
        }
        #endif
    }

    /// Sets the handoff manager for activity updates.
    func setHandoffManager(_ manager: HandoffManager) {
        self.handoffManager = manager
    }

    #if os(iOS)
    /// Called when the player sheet appears.
    func playerSheetDidAppear() {
        let backendType = currentBackend?.backendType.rawValue ?? "none"
        let playbackState = state.playbackState
        LoggingService.shared.debug("PlayerService: playerSheetDidAppear - backend=\(backendType), playbackState=\(playbackState)", category: .player)

        // Re-enable visual tracks and reattach layer when sheet appears
        let isPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
        LoggingService.shared.debug("PlayerService: sheetDidAppear checks - isPiPActive=\(isPiPActive)", category: .player)

        guard !isPiPActive else {
            LoggingService.shared.debug("PlayerService: skipping visibility handling (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 mpvPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
        LoggingService.shared.debug("PlayerService: sheetDidDisappear checks - mpvPiPActive=\(mpvPiPActive)", category: .player)

        guard !mpvPiPActive else {
            LoggingService.shared.debug("PlayerService: skipping visibility handling on disappear (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.
        // Live streams still get a history entry, but no resume position.
        dataManager.updateWatchProgressLocal(for: video, seconds: state.currentTime, duration: state.duration, isLive: state.isLive)

        // 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 }

        // A live stream never "completes" - saveProgress() has already recorded
        // its history entry, and writing a duration here would fabricate progress.
        guard !state.isLive 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 and queue for iCloud sync.
        // This is the only save for a naturally finished video — play() skips
        // saveProgressAndSync() when videoEndedNaturally is set.
        dataManager.updateWatchProgress(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, isLive: state.isLive)
        NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
    }

    /// Saves current progress and queues it for iCloud sync without ending playback.
    /// Called when the app enters background so the position reaches other devices.
    func syncWatchProgress() {
        saveProgressAndSync()
    }

    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)

        // Clear the time-update gate once the new video is loaded, in case the load
        // flow wedged before doing so (issue #956). Only .ready: it comes from mpv's
        // file-loaded event, which only the new load can emit. The previous video
        // (still playing on the reused backend during the details/streams fetch) can
        // emit .playing via buffering recovery or unpause — healing on that would
        // open the gate to stale time updates and break the stale-error check in
        // play()'s catch, silently swallowing genuine load failures.
        if playbackState == .ready, loadingVideoID != nil {
            loadingVideoID = nil
        }

        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, didUpdateTracks tracks: [MPVTrack]) {
        embeddedTracks = tracks
        reapplyOrAutoSelectEmbeddedTracks()
    }

    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)

        // Repeat one: restart immediately - no countdown, independent of queue contents
        // and the auto-play-next setting (that setting governs advancing to the next video)
        if state.queueMode == .repeatOne {
            Task {
                await playNext()
            }
            return
        }

        // 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()
        }
    }
}
