mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
Fix tvOS MPV startup playback stability
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
Reference in New Issue
Block a user