fix(tv): resolve mpv player exit freeze by async mpv cleanup

This commit is contained in:
Fredrik Burmester
2026-01-22 09:08:51 +01:00
parent 093fcc6187
commit 1b80db678e
2 changed files with 66 additions and 17 deletions

View File

@@ -24,4 +24,10 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_

View File

@@ -30,8 +30,11 @@ final class MPVLayerRenderer {
}
private let displayLayer: AVSampleBufferDisplayLayer
private let queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated)
private let queue: DispatchQueue
private let stateQueue = DispatchQueue(label: "mpv.avfoundation.state", attributes: .concurrent)
// Key to identify if we're on the mpv queue (to avoid deadlock in stop())
private static let queueKey = DispatchSpecificKey<Bool>()
private var mpv: OpaquePointer?
@@ -42,8 +45,18 @@ final class MPVLayerRenderer {
private var initialSubtitleId: Int?
private var initialAudioId: Int?
private var isRunning = false
private var isStopping = false
private var _isRunning = false
private var _isStopping = false
private var isRunning: Bool {
get { stateQueue.sync { _isRunning } }
set { stateQueue.async(flags: .barrier) { self._isRunning = newValue } }
}
private var isStopping: Bool {
get { stateQueue.sync { _isStopping } }
set { stateQueue.sync(flags: .barrier) { _isStopping = newValue } } // Must be sync for stop() to work
}
// KVO observation for display layer status
private var statusObservation: NSKeyValueObservation?
@@ -116,6 +129,8 @@ final class MPVLayerRenderer {
init(displayLayer: AVSampleBufferDisplayLayer) {
self.displayLayer = displayLayer
self.queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated)
queue.setSpecific(key: Self.queueKey, value: true)
observeDisplayLayerStatus()
}
@@ -176,10 +191,16 @@ final class MPVLayerRenderer {
// Use AVFoundation video output - required for PiP support
checkError(mpv_set_option_string(handle, "vo", "avfoundation"))
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
// This is better for PiP as subtitles are baked into the video
// NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit
// Composite OSD mode - renders subtitles directly onto video frames using GPU
// CRITICAL: This option MUST be set immediately after vo=avfoundation, before hwdec options.
// On tvOS, moving this elsewhere causes the app to freeze when exiting the player.
// - iOS: "yes" for PiP subtitle support (subtitles baked into video)
// - tvOS: "no" to prevent gray tint + frame drops with subtitles
#if os(tvOS)
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no"))
#else
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
#endif
// Hardware decoding with VideoToolbox
// On simulator, use software decoding since VideoToolbox is not available
@@ -225,19 +246,41 @@ final class MPVLayerRenderer {
if !isRunning, mpv == nil { return }
isRunning = false
isStopping = true
// Stop observing display layer status
statusObservation?.invalidate()
statusObservation = nil
queue.sync { [weak self] in
guard let self, let handle = self.mpv else { return }
// Clear wakeup callback first to stop event processing
if let handle = mpv {
mpv_set_wakeup_callback(handle, nil, nil)
mpv_terminate_destroy(handle)
self.mpv = nil
// Send quit command and drain events on the mpv queue
queue.sync { [weak self] in
guard let self, let handle = self.mpv else { return }
self.commandSync(handle, ["quit"])
// Drain any remaining events after quit
var drainCount = 0
let maxDrain = 100
while drainCount < maxDrain, let event = mpv_wait_event(handle, 0.1)?.pointee {
if event.event_id == MPV_EVENT_NONE || event.event_id == MPV_EVENT_SHUTDOWN {
break
}
drainCount += 1
}
}
// Call mpv_terminate_destroy on a background thread to avoid blocking main
// mpv_terminate_destroy may need main thread for AVFoundation cleanup,
// so we can't call it while blocking main with queue.sync
let handleToDestroy = handle
mpv = nil // Clear immediately so nothing else uses it
DispatchQueue.global(qos: .userInitiated).async {
mpv_terminate_destroy(handleToDestroy)
}
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if #available(iOS 18.0, tvOS 17.0, *) {
@@ -246,7 +289,7 @@ final class MPVLayerRenderer {
self.displayLayer.flushAndRemoveImage()
}
}
isStopping = false
}
@@ -402,7 +445,7 @@ final class MPVLayerRenderer {
private func processEvents() {
queue.async { [weak self] in
guard let self else { return }
while self.mpv != nil && !self.isStopping {
guard let handle = self.mpv,
let eventPointer = mpv_wait_event(handle, 0) else { return }