From 1b80db678e79044920927fd91a8982269efb6508 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 09:08:51 +0100 Subject: [PATCH] fix(tv): resolve mpv player exit freeze by async mpv cleanup --- .claude/learned-facts.md | 8 +- modules/mpv-player/ios/MPVLayerRenderer.swift | 75 +++++++++++++++---- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md index 0dba9d7e..67a2243c 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -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)_ \ No newline at end of file +- **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)_ \ No newline at end of file diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index 35b112eb..af55826f 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -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() 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 }