Fix black video in sheet-mode player and mini capsule hand-off delays

Sheet mode has no container lifecycle hook on either transition, so the
shared render view was stuck until the render watchdog (~10s of black):

- Presenting the sheet: the capsule parks the view while the sheet's
  window exists but is not yet visible - viewDidMoveToWindow has already
  fired and findTransferTarget rejects non-visible windows, so nothing
  picked the parked view up.
- Dismissing the sheet: the dismissed sheet's hierarchy stays alive
  holding the view inside a now-invisible window, with no unmount or
  park at all.

Add scheduleSharedViewAdoptionRetry() to MPVContainerNSView: for ~1s it
re-checks every 50ms whether the shared view needs re-homing (parked,
or owned by a container whose window lost visibility) and runs the same
recovery the watchdog uses. Deduplicated so overlapping triggers don't
stack loops. Triggered after parking with no transfer target and from
hide()'s no-window branch, which every sheet-mode collapse funnels
through.

The retry ticks pass quiet: true to recoverSharedPlayerViewIfNeeded so
teardown after closing a video (when no container legitimately exists)
no longer spams warnings, and the loop stops once the shared view is
gone.
This commit is contained in:
Arkadiusz Fal
2026-06-23 21:24:13 +02:00
parent 945804c109
commit 7808d4c730
2 changed files with 55 additions and 5 deletions

View File

@@ -192,6 +192,12 @@ final class ExpandedPlayerWindowManager: NSObject {
/// - completion: Called after the window is hidden
func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
guard let window = playerWindow else {
// No window means sheet mode (or the window is already gone).
// A dismissed sheet's hierarchy stays alive holding the shared
// render view inside a now-invisible window with no container
// lifecycle event to react to - watch for the dismissal to finish
// and re-home the view to the mini capsule.
MPVContainerNSView.scheduleSharedViewAdoptionRetry()
completion?()
return
}

View File

@@ -279,6 +279,7 @@ final class MPVContainerNSView: NSView {
category: .mpv
)
playerView.removeFromSuperview()
MPVContainerNSView.scheduleSharedViewAdoptionRetry()
}
}
currentPlayerView = nil
@@ -380,12 +381,53 @@ final class MPVContainerNSView: NSView {
MPVContainerNSView.sharedPlayerView = playerView
}
/// True while an adoption retry loop is running - avoids stacking loops
/// when several triggers fire in quick succession.
private static var adoptionRetryActive = false
/// Watch the shared player view for ~1s and re-home it as soon as it
/// needs it (parked with no owner, or owned by a container whose window
/// lost visibility). This covers transitions with no container lifecycle
/// hook to react to:
/// - parking while the next host's window exists but is not yet visible
/// (sheet mid-presentation: viewDidMoveToWindow already fired and
/// findTransferTarget rejects non-visible windows)
/// - sheet dismissal, where the dismissed sheet's hierarchy stays alive
/// holding the view inside a now-invisible window
/// Without this the video stays black until the render watchdog (~10s).
static func scheduleSharedViewAdoptionRetry() {
guard !adoptionRetryActive else { return }
adoptionRetryActive = true
adoptionRetryTick(attempt: 0)
}
private static func adoptionRetryTick(attempt: Int) {
guard attempt < 20 else {
adoptionRetryActive = false
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
guard sharedPlayerView != nil else {
adoptionRetryActive = false
return
}
// quiet: while playback is tearing down there legitimately is no
// container to adopt the view - not worth a warning per tick.
if recoverSharedPlayerViewIfNeeded(quiet: true) {
adoptionRetryActive = false
(sharedPlayerView as? MPVOGLView)?.resumeRendering()
} else {
adoptionRetryTick(attempt: attempt + 1)
}
}
}
/// Re-attach the shared player view to a container in a visible window when
/// it is currently orphaned or parented somewhere non-visible. Called by
/// MPVBackend's render watchdog when video output stalls (frames consumed
/// without a single draw). Returns true when the view was re-parented.
@discardableResult
static func recoverSharedPlayerViewIfNeeded() -> Bool {
static func recoverSharedPlayerViewIfNeeded(quiet: Bool = false) -> Bool {
guard let sharedView = sharedPlayerView else { return false }
let owner = sharedView.superview as? MPVContainerNSView
// Parented outside any container not ours to manage.
@@ -417,10 +459,12 @@ final class MPVContainerNSView: NSView {
.filter { $0.window?.isVisible == true }
.max(by: { area($0) < area($1) })
guard let target, target !== owner else {
LoggingService.shared.warning(
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: no suitable container (owner: \(owner?.shortID ?? "none"), trackedWindowVisible: \(trackedWindowVisible))",
category: .mpv
)
if !quiet {
LoggingService.shared.warning(
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: no suitable container (owner: \(owner?.shortID ?? "none"), trackedWindowVisible: \(trackedWindowVisible))",
category: .mpv
)
}
return false
}
LoggingService.shared.warning(