Fix macOS permanent black video after player collapse/expand cycles

In separate-window mode, hide() keeps the ordered-out window's SwiftUI
hierarchy alive (nilling contentViewController crashes AVKit), and its
updateNSView unconditionally re-attached the shared MPVOGLView -
stealing it into a window CoreAnimation never composites. The layer's
silent skip-render fallback then consumed every frame flag, so video
stayed black (climbing vo drops) until app restart.

- Decline stealing the shared view from a visible container into a
  non-visible one; owner-initiated transfers bypass the guard
- Only transfer to containers in visible windows or the window still
  tracked by ExpandedPlayerWindowManager; park the view otherwise and
  reclaim it when a container gains a window
- Log skip-render frame consumption (rate-limited) and track render
  health counters (draws/skips/dropped draws)
- Add a watchdog to the playback stats task that detects a stalled
  video output and re-attaches the view to a visible container
- Don't mount player layouts during zero-size layout passes
This commit is contained in:
Arkadiusz Fal
2026-06-21 21:55:37 +02:00
parent 1cd566ae29
commit b82241564d
6 changed files with 270 additions and 15 deletions

View File

@@ -303,7 +303,14 @@ struct ExpandedPlayerSheet: View {
Color.black.ignoresSafeArea(edges: .bottom)
#if os(iOS) || os(macOS)
if wideScreen {
if geometry.size.width <= 0 || geometry.size.height <= 0 {
// Transient zero-size layout pass (e.g. the deferred macOS
// player window before its first real layout). Don't mount
// either layout branch a branch mounted here would grab
// the shared render view for one frame and immediately hand
// it off when the real size picks the other branch.
Color.black.ignoresSafeArea(.all)
} else if wideScreen {
if let video = playerState?.currentVideo {
// Widescreen layout with floating panel
// Must ignore safe area to get full screen geometry

View File

@@ -36,6 +36,15 @@ final class ExpandedPlayerWindowManager: NSObject {
playerWindow != nil
}
/// The live player window managed by this instance (nil after hide()).
/// A window that exists here but isn't visible is mid-presentation or
/// hidden for PiP both valid homes for the shared render view, unlike a
/// stale ordered-out window that is no longer tracked. Used by
/// MPVContainerNSView when picking a transfer target.
var currentPlayerWindow: NSWindow? {
playerWindow
}
private override init() {
super.init()
}

View File

@@ -167,15 +167,25 @@ struct MPVRenderViewRepresentable: NSViewRepresentable {
}
}
/// Container view that properly manages player view swapping on macOS
private class MPVContainerNSView: NSView {
/// Container view that properly manages player view swapping on macOS.
/// Internal (not private) so MPVBackend's render watchdog can call
/// `recoverSharedPlayerViewIfNeeded()`.
final class MPVContainerNSView: NSView {
private weak var currentPlayerView: NSView?
private let containerID = UUID()
/// Short identifier used in logs.
var shortID: String { String(containerID.uuidString.prefix(8)) }
/// Track all living containers so a container that releases the shared
/// player view can hand it to another container instead of orphaning it.
private static var livingContainers = NSHashTable<MPVContainerNSView>.weakObjects()
/// The single shared render view most recently attached to any container.
/// Lets a container that gains a window reclaim the view when it was
/// orphaned or left behind in a non-visible window.
private static weak var sharedPlayerView: NSView?
/// Callback when view is added to a window
var onDidMoveToWindow: (() -> Void)?
@@ -194,9 +204,30 @@ private class MPVContainerNSView: NSView {
// Notify when we're added to a window (not removed)
if window != nil {
onDidMoveToWindow?()
reclaimSharedPlayerViewIfStranded()
}
}
/// When this container gains a window, adopt the shared player view if it
/// is currently orphaned or parented in a container whose window is gone
/// or not visible. This is the recovery path for the expand race where the
/// mini capsule unmounts before any windowed container exists ("no
/// transfer target"), and for a view left behind in a stale ordered-out
/// player window.
private func reclaimSharedPlayerViewIfStranded() {
guard let sharedView = Self.sharedPlayerView, sharedView.superview !== self else { return }
let owner = sharedView.superview as? MPVContainerNSView
// Parented outside any container (e.g. mid-transfer) leave it alone.
if sharedView.superview != nil, owner == nil { return }
let ownerWindowVisible = owner?.window?.isVisible == true
guard owner == nil || !ownerWindowVisible else { return }
LoggingService.shared.debug(
"MPVContainerNSView[\(shortID)]: reclaiming stranded player view on window attach (previous owner: \(owner?.shortID ?? "none"), ownerWindowVisible: \(ownerWindowVisible))",
category: .mpv
)
setPlayerView(sharedView)
}
override func viewWillMove(toSuperview newSuperview: NSView?) {
super.viewWillMove(toSuperview: newSuperview)
// When being removed from superview, detach the player view first for a clean
@@ -219,13 +250,16 @@ private class MPVContainerNSView: NSView {
// view can end up with no superview and the player renders only black.
if let target = MPVContainerNSView.findTransferTarget(excluding: self) {
LoggingService.shared.debug(
"MPVContainerNSView[\(containerID.uuidString.prefix(8))]: unmounting - transferring player view to container \(target.containerID.uuidString.prefix(8)) (windowVisible: \(target.window?.isVisible == true))",
"MPVContainerNSView[\(shortID)]: unmounting - transferring player view to container \(target.shortID) (windowVisible: \(target.window?.isVisible == true))",
category: .mpv
)
target.setPlayerView(playerView)
// The transfer is initiated by the current owner, so bypass
// the steal guard (the target may legitimately be in a
// not-yet-visible window, e.g. hidden for PiP).
target.setPlayerView(playerView, bypassingStealGuard: true)
} else {
LoggingService.shared.debug(
"MPVContainerNSView[\(containerID.uuidString.prefix(8))]: unmounting - no transfer target, removing player view",
"MPVContainerNSView[\(shortID)]: unmounting - no transfer target, parking player view (reclaimed when a container gains a window)",
category: .mpv
)
playerView.removeFromSuperview()
@@ -236,15 +270,32 @@ private class MPVContainerNSView: NSView {
}
}
/// Another living container that can host the shared player view, preferring
/// one whose window is currently visible (a restoring window may not be
/// visible yet at transfer time, so a hidden window is still acceptable).
/// Another living container that can host the shared player view.
/// Ranking:
/// 1. A container whose window is currently visible.
/// 2. A container in the window still tracked by ExpandedPlayerWindowManager
/// (mid-presentation or hidden for PiP it will become visible).
/// 3. nil park the view rather than parenting it in a stale ordered-out
/// window that CoreAnimation never composites (permanent black video);
/// `reclaimSharedPlayerViewIfStranded` re-adopts it when a container
/// gains a window.
private static func findTransferTarget(excluding source: MPVContainerNSView) -> MPVContainerNSView? {
let candidates = livingContainers.allObjects.filter { $0 !== source && $0.window != nil }
return candidates.first { $0.window?.isVisible == true } ?? candidates.first
if let visible = candidates.first(where: { $0.window?.isVisible == true }) {
return visible
}
if let trackedWindow = ExpandedPlayerWindowManager.shared.currentPlayerWindow,
let tracked = candidates.first(where: { $0.window === trackedWindow }) {
LoggingService.shared.debug(
"MPVContainerNSView.findTransferTarget: no visible candidate, using container \(tracked.shortID) in tracked player window",
category: .mpv
)
return tracked
}
return nil
}
func setPlayerView(_ playerView: NSView) {
func setPlayerView(_ playerView: NSView, bypassingStealGuard: Bool = false) {
// Skip only if same view AND actually our subview. The weak ref can
// point to a view that was stolen by another container e.g. the mini
// player preview takes the shared render view during PiP while the
@@ -254,9 +305,27 @@ private class MPVContainerNSView: NSView {
return
}
// Refuse to steal the shared view from a container in a visible window
// when this container's own window is missing or not visible. The
// player window ordered out on collapse keeps its SwiftUI hierarchy
// alive until it deallocates, and its updateNSView would otherwise
// re-parent the shared view into the dead window where CoreAnimation
// never composites it (permanent black video until app restart).
if !bypassingStealGuard,
let owner = playerView.superview as? MPVContainerNSView,
owner !== self,
owner.window?.isVisible == true,
window?.isVisible != true {
LoggingService.shared.debug(
"MPVContainerNSView[\(shortID)]: declined steal - view owned by visible container \(owner.shortID) (self window: \(window != nil), windowVisible: false)",
category: .mpv
)
return
}
let previousSuperview = playerView.superview.map { String(describing: type(of: $0)) } ?? "nil"
LoggingService.shared.debug(
"MPVContainerNSView[\(containerID.uuidString.prefix(8))].setPlayerView: attaching (reclaim: \(playerView === currentPlayerView), previousSuperview: \(previousSuperview), window: \(window != nil), windowVisible: \(window?.isVisible == true))",
"MPVContainerNSView[\(shortID)].setPlayerView: attaching (reclaim: \(playerView === currentPlayerView), previousSuperview: \(previousSuperview), window: \(window != nil), windowVisible: \(window?.isVisible == true))",
category: .mpv
)
@@ -276,6 +345,34 @@ private class MPVContainerNSView: NSView {
])
currentPlayerView = playerView
MPVContainerNSView.sharedPlayerView = playerView
}
/// 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 {
guard let sharedView = sharedPlayerView else { return false }
let owner = sharedView.superview as? MPVContainerNSView
// Healthy: owned by a container in a visible window.
if owner?.window?.isVisible == true { return false }
// Parented outside any container not ours to manage.
if sharedView.superview != nil, owner == nil { return false }
guard let target = livingContainers.allObjects.first(where: { $0.window?.isVisible == true }) else {
LoggingService.shared.warning(
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: no visible container available (owner: \(owner?.shortID ?? "none"))",
category: .mpv
)
return false
}
LoggingService.shared.warning(
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: re-attaching player view from \(owner?.shortID ?? "orphaned") to container \(target.shortID)",
category: .mpv
)
target.setPlayerView(sharedView, bypassingStealGuard: true)
return sharedView.superview === target
}
}