diff --git a/Yattee/Services/Player/MPV/MPVClient.swift b/Yattee/Services/Player/MPV/MPVClient.swift index 071bc631..0b0dc0ed 100644 --- a/Yattee/Services/Player/MPV/MPVClient.swift +++ b/Yattee/Services/Player/MPV/MPVClient.swift @@ -456,8 +456,19 @@ final class MPVClient: @unchecked Sendable { // Cache settings for network streams setOptionSync("cache", "yes") + #if os(tvOS) + // Apple TV can decode 1080p60 easily, but aggressively filling a large + // demuxer cache during startup competes with the first seconds of + // rendering. Keep enough readahead for smooth playback without racing + // hundreds of seconds ahead immediately after load/seek. + setOptionSync("cache-secs", "30") + setOptionSync("demuxer-readahead-secs", "20") + setOptionSync("demuxer-max-bytes", "24MiB") + setOptionSync("demuxer-max-back-bytes", "8MiB") + #else setOptionSync("demuxer-max-bytes", "50MiB") setOptionSync("demuxer-max-back-bytes", "25MiB") + #endif // Logging - minimal logging for release builds setOptionSync("terminal", "no") diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 6bab81e9..44bf5439 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -464,6 +464,8 @@ final class MPVBackend: PlayerBackend { currentTime = 0 duration = 0 bufferedTime = 0 + containerFps = 0 + renderView?.videoFPS = 60 // When loading with external audio (non-EDL mode), wait for PLAYBACK_RESTART after audio is added // With EDL, both streams load atomically so no waiting needed isWaitingForExternalAudio = audioStream != nil && !useEDL @@ -474,6 +476,7 @@ final class MPVBackend: PlayerBackend { // Reset first-frame tracking for new content renderView?.resetFirstFrameTracking() + applyStreamFrameRateHint(stream) // Pause MPV before loading new content to prevent audio from playing // before the thumbnail hides. This is critical when reusing the backend @@ -1743,6 +1746,17 @@ extension MPVBackend: MPVClientDelegate { } #endif + /// Seed render/display cadence from API metadata before MPV finishes probing. + /// MPV will overwrite this later with the exact container FPS when available. + private func applyStreamFrameRateHint(_ stream: Stream) { + guard let fps = stream.fps, fps > 0 else { return } + containerFps = Double(fps) + updateRenderViewFPS() + #if os(tvOS) + applyTVDisplayCriteria() + #endif + } + /// Update render view's video FPS for display link frame rate matching private func updateRenderViewFPS() { // Use cached container-fps (set via property observation to avoid sync fetch on main thread) @@ -1778,6 +1792,12 @@ extension MPVBackend: MPVClientDelegate { /// This is a fallback when no video frames or dimensions are available private func checkAndMarkReadyIfAudioOnlyDetected() { guard !isReady, !isSeeking else { return } + // Only apply this path for streams the user actually selected as audio-only. + // For video streams with a separate audio track (DASH), PLAYBACK_RESTART can + // fire before mpv has demuxed the video — videoWidth/codec are still 0 — and + // we'd incorrectly mark ready as audio-only and start playback before video + // decoding catches up, producing an audible startup A/V desync. + guard currentStream?.isAudioOnly == true else { return } // Detect audio-only: no video codec and no video dimensions from MPV guard videoCodec.isEmpty, videoWidth == 0, videoHeight == 0 else { return } // Ensure stream metadata is loaded diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index f4dbecef..54665eee 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -404,7 +404,9 @@ final class PlayerService { // Check for cancellation after stream load completes try Task.checkCancellation() - await backend.seek(to: seekTime, showLoading: false) + if seekTime > 0 { + await backend.seek(to: seekTime, showLoading: false) + } // Wait for player sheet animation to complete before starting playback await navigationCoordinator?.waitForPlayerSheetAnimation() diff --git a/Yattee/Services/Player/TVDisplayModeManager.swift b/Yattee/Services/Player/TVDisplayModeManager.swift index 081abe94..c3d8511c 100644 --- a/Yattee/Services/Player/TVDisplayModeManager.swift +++ b/Yattee/Services/Player/TVDisplayModeManager.swift @@ -78,8 +78,8 @@ final class TVDisplayModeManager { /// Pass `nil` for fields you don't have yet; this method is safe to call /// repeatedly as more info becomes available (e.g. fps arrives before gamma). func apply(fps: Double?, gamma: String?) { - let matchFrameRate = readBoolDefaultFalse(key: "tvMatchDisplayFrameRate") - let matchDynamicRange = readBoolDefaultFalse(key: "tvMatchDisplayDynamicRange") + let matchFrameRate = readBool(key: "tvMatchDisplayFrameRate", default: false) + let matchDynamicRange = readBool(key: "tvMatchDisplayDynamicRange", default: false) guard matchFrameRate || matchDynamicRange else { clear() @@ -136,10 +136,13 @@ final class TVDisplayModeManager { // MARK: - Helpers - private func readBoolDefaultFalse(key: String) -> Bool { + private func readBool(key: String, default defaultValue: Bool) -> Bool { // The SettingsManager stores these unprefixed (not platform-specific keys), - // so we can read them directly from standard UserDefaults. Missing key = off. - UserDefaults.standard.bool(forKey: key) + // so we can read them directly from standard UserDefaults. + guard UserDefaults.standard.object(forKey: key) != nil else { + return defaultValue + } + return UserDefaults.standard.bool(forKey: key) } private func activeDisplayManager() -> AVDisplayManager? {