Fix tvOS MPV startup playback stability

This commit is contained in:
Arkadiusz Fal
2026-05-10 12:40:25 +02:00
parent 82d2830208
commit 6e5714dd86
4 changed files with 42 additions and 6 deletions

View File

@@ -456,8 +456,19 @@ final class MPVClient: @unchecked Sendable {
// Cache settings for network streams // Cache settings for network streams
setOptionSync("cache", "yes") 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-bytes", "50MiB")
setOptionSync("demuxer-max-back-bytes", "25MiB") setOptionSync("demuxer-max-back-bytes", "25MiB")
#endif
// Logging - minimal logging for release builds // Logging - minimal logging for release builds
setOptionSync("terminal", "no") setOptionSync("terminal", "no")

View File

@@ -464,6 +464,8 @@ final class MPVBackend: PlayerBackend {
currentTime = 0 currentTime = 0
duration = 0 duration = 0
bufferedTime = 0 bufferedTime = 0
containerFps = 0
renderView?.videoFPS = 60
// When loading with external audio (non-EDL mode), wait for PLAYBACK_RESTART after audio is added // 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 // With EDL, both streams load atomically so no waiting needed
isWaitingForExternalAudio = audioStream != nil && !useEDL isWaitingForExternalAudio = audioStream != nil && !useEDL
@@ -474,6 +476,7 @@ final class MPVBackend: PlayerBackend {
// Reset first-frame tracking for new content // Reset first-frame tracking for new content
renderView?.resetFirstFrameTracking() renderView?.resetFirstFrameTracking()
applyStreamFrameRateHint(stream)
// Pause MPV before loading new content to prevent audio from playing // Pause MPV before loading new content to prevent audio from playing
// before the thumbnail hides. This is critical when reusing the backend // before the thumbnail hides. This is critical when reusing the backend
@@ -1743,6 +1746,17 @@ extension MPVBackend: MPVClientDelegate {
} }
#endif #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 /// Update render view's video FPS for display link frame rate matching
private func updateRenderViewFPS() { private func updateRenderViewFPS() {
// Use cached container-fps (set via property observation to avoid sync fetch on main thread) // 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 /// This is a fallback when no video frames or dimensions are available
private func checkAndMarkReadyIfAudioOnlyDetected() { private func checkAndMarkReadyIfAudioOnlyDetected() {
guard !isReady, !isSeeking else { return } 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 // Detect audio-only: no video codec and no video dimensions from MPV
guard videoCodec.isEmpty, videoWidth == 0, videoHeight == 0 else { return } guard videoCodec.isEmpty, videoWidth == 0, videoHeight == 0 else { return }
// Ensure stream metadata is loaded // Ensure stream metadata is loaded

View File

@@ -404,7 +404,9 @@ final class PlayerService {
// Check for cancellation after stream load completes // Check for cancellation after stream load completes
try Task.checkCancellation() 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 // Wait for player sheet animation to complete before starting playback
await navigationCoordinator?.waitForPlayerSheetAnimation() await navigationCoordinator?.waitForPlayerSheetAnimation()

View File

@@ -78,8 +78,8 @@ final class TVDisplayModeManager {
/// Pass `nil` for fields you don't have yet; this method is safe to call /// 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). /// repeatedly as more info becomes available (e.g. fps arrives before gamma).
func apply(fps: Double?, gamma: String?) { func apply(fps: Double?, gamma: String?) {
let matchFrameRate = readBoolDefaultFalse(key: "tvMatchDisplayFrameRate") let matchFrameRate = readBool(key: "tvMatchDisplayFrameRate", default: false)
let matchDynamicRange = readBoolDefaultFalse(key: "tvMatchDisplayDynamicRange") let matchDynamicRange = readBool(key: "tvMatchDisplayDynamicRange", default: false)
guard matchFrameRate || matchDynamicRange else { guard matchFrameRate || matchDynamicRange else {
clear() clear()
@@ -136,10 +136,13 @@ final class TVDisplayModeManager {
// MARK: - Helpers // 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), // The SettingsManager stores these unprefixed (not platform-specific keys),
// so we can read them directly from standard UserDefaults. Missing key = off. // so we can read them directly from standard UserDefaults.
UserDefaults.standard.bool(forKey: key) guard UserDefaults.standard.object(forKey: key) != nil else {
return defaultValue
}
return UserDefaults.standard.bool(forKey: key)
} }
private func activeDisplayManager() -> AVDisplayManager? { private func activeDisplayManager() -> AVDisplayManager? {