mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-24 12:08:05 +00:00
fix(tv): resolve mpv player exit freeze by async mpv cleanup
This commit is contained in:
@@ -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)_
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user