mirror of
https://github.com/yattee/yattee.git
synced 2026-10-29 16:07:20 +00:00
Stop decoding video during background playback on iOS
Pausing rendering in the background left mpv decoding every frame (hardware decode plus copy back) only to drop it, keeping a 1080p video at ~15% CPU with the screen off. After 5 s in the background without PiP the video track is now disabled (vid=no); returning to the foreground re-enables it and seeks exactly to the current position, which keeps audio and video in sync (avsync 0.000 vs -0.044 and 22 drops without the seek). Streams loaded while backgrounded start with video disabled. Background CPU drops to ~3.7% on an iPhone 12 mini.
This commit is contained in:
@@ -1142,6 +1142,21 @@ final class MPVClient: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a double property value asynchronously (non-blocking).
|
||||
func getDoubleAsync(_ name: String) async -> Double? {
|
||||
await withCheckedContinuation { continuation in
|
||||
mpvQueue.async { [weak self] in
|
||||
guard let self, let mpv = self.mpv, !self.isDestroyed else {
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
var value: Double = 0
|
||||
let result = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
|
||||
continuation.resume(returning: result >= 0 ? value : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get MPV version and build information asynchronously (non-blocking).
|
||||
/// Use this from MainActor code instead of getVersionInfo() to avoid blocking the UI.
|
||||
/// - Returns: The version information, or nil if unavailable or MPV is destroyed
|
||||
|
||||
@@ -440,6 +440,7 @@ final class MPVBackend: PlayerBackend {
|
||||
// Store for potential retries
|
||||
currentStream = stream
|
||||
retryCount = 0
|
||||
loadedStreamHasVideo = !stream.isAudioOnly
|
||||
|
||||
// Reset retry state in UI
|
||||
delegate?.backend(self, didUpdateRetryState: 0, maxRetries: maxRetries, isRetrying: false, exhausted: false)
|
||||
@@ -507,7 +508,7 @@ final class MPVBackend: PlayerBackend {
|
||||
audioURL: audioStream?.url,
|
||||
httpHeaders: stream.httpHeaders,
|
||||
useEDL: useEDL,
|
||||
disableVideoTrack: stream.requiresVideoTrackDisabled
|
||||
disableVideoTrack: stream.requiresVideoTrackDisabled || isVideoDisabledForBackground
|
||||
)
|
||||
|
||||
// Give MPV a moment to process the loadfile command
|
||||
@@ -778,6 +779,7 @@ final class MPVBackend: PlayerBackend {
|
||||
duration = 0
|
||||
bufferedTime = 0
|
||||
currentStream = nil
|
||||
loadedStreamHasVideo = false
|
||||
#if os(tvOS)
|
||||
videoGamma = ""
|
||||
clearTVDisplayCriteria()
|
||||
@@ -977,6 +979,12 @@ final class MPVBackend: PlayerBackend {
|
||||
MPVLogging.logAppLifecycle("handleScenePhase(\(phase))",
|
||||
isPiPActive: pipActive, isRendering: nil)
|
||||
|
||||
#if os(iOS)
|
||||
if phase == .active {
|
||||
restoreVideoAfterBackground()
|
||||
}
|
||||
#endif
|
||||
|
||||
guard !pipActive else {
|
||||
MPVLogging.log("handleScenePhase: skipping (pip:\(pipActive))")
|
||||
return
|
||||
@@ -996,6 +1004,9 @@ final class MPVBackend: PlayerBackend {
|
||||
#else
|
||||
(playerView as? MPVRenderView)?.pauseRendering()
|
||||
#endif
|
||||
#if os(iOS)
|
||||
scheduleBackgroundVideoDisable()
|
||||
#endif
|
||||
|
||||
case .active:
|
||||
// Resume rendering
|
||||
@@ -1014,6 +1025,73 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Video Decoding
|
||||
|
||||
#if os(iOS)
|
||||
/// Seconds in the background before video decoding is turned off, so a
|
||||
/// quick app switch returns without the resync hitch.
|
||||
private static let backgroundVideoDisableDelay: TimeInterval = 5
|
||||
|
||||
private var backgroundVideoDisableWork: DispatchWorkItem?
|
||||
#endif
|
||||
|
||||
/// Video decoding is off (`vid=no`) because the app is in the background
|
||||
/// without PiP. Pausing rendering alone keeps mpv decoding every frame
|
||||
/// (hardware decode plus a copy back to memory) only to drop it, which
|
||||
/// heats the device during long background playback.
|
||||
private var isVideoDisabledForBackground = false
|
||||
|
||||
/// Whether the loaded stream has a video track to decode. (`currentStream`
|
||||
/// is only kept for retries and is cleared once loading succeeds.)
|
||||
private var loadedStreamHasVideo = false
|
||||
|
||||
#if os(iOS)
|
||||
private func scheduleBackgroundVideoDisable() {
|
||||
backgroundVideoDisableWork?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.disableVideoForBackground()
|
||||
}
|
||||
backgroundVideoDisableWork = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.backgroundVideoDisableDelay, execute: work)
|
||||
}
|
||||
|
||||
private func disableVideoForBackground() {
|
||||
backgroundVideoDisableWork = nil
|
||||
guard !isVideoDisabledForBackground, !isPiPActive, loadedStreamHasVideo else { return }
|
||||
isVideoDisabledForBackground = true
|
||||
LoggingService.shared.info("MPV: Disabling video decoding in background", category: .mpv)
|
||||
mpvClient?.setProperty("vid", "no")
|
||||
}
|
||||
|
||||
private func restoreVideoAfterBackground() {
|
||||
backgroundVideoDisableWork?.cancel()
|
||||
backgroundVideoDisableWork = nil
|
||||
guard isVideoDisabledForBackground else { return }
|
||||
isVideoDisabledForBackground = false
|
||||
// A stream loaded while disabled may be audio-only (vid stays off)
|
||||
guard loadedStreamHasVideo else { return }
|
||||
|
||||
LoggingService.shared.info("MPV: Re-enabling video decoding at \(String(format: "%.2f", currentTime))s", category: .mpv)
|
||||
mpvClient?.setProperty("vid", "auto")
|
||||
// Video re-enabled live starts behind the audio (see
|
||||
// handlePlayerSheetVisibility); an exact seek to the current position
|
||||
// restarts both in sync.
|
||||
mpvClient?.seekAsync(to: currentTime, mode: "absolute+exact")
|
||||
|
||||
// Report how well audio and video line up once playback settles
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
guard let client = self?.mpvClient else { return }
|
||||
let avsync = await client.getDoubleAsync("avsync")
|
||||
let drops = await client.getDoubleAsync("frame-drop-count")
|
||||
LoggingService.shared.info(
|
||||
"MPV: Video resynced after background - avsync=\(avsync.map { String(format: "%.3f", $0) } ?? "n/a")s, dropped=\(drops.map { String(Int($0)) } ?? "n/a")",
|
||||
category: .mpv
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Handles player sheet visibility changes for background audio playback.
|
||||
/// - Parameter isVisible: true when sheet appears, false when it disappears
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user