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:
Arkadiusz Fal
2026-10-27 23:25:52 +01:00
parent bebb62068a
commit cdcbe51370
2 changed files with 94 additions and 1 deletions

View File

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

View File

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