Fix macOS player window black video when mini capsule steals render view

Closing the player window via its close button left isPlayerCollapsing
stuck true (windowShouldClose has no animation completion to clear it),
so the mini capsule preview kept mounting its render container and
grabbed the shared render view on the next expand - the video rendered
into the 44x26 thumbnail while the player window stayed black.

- Clear isPlayerCollapsing on the next runloop in windowShouldClose
- Give containers in the tracked player window priority to claim the
  shared view (even from a visible owner), and refuse steals from the
  visible player window by outside containers
- Prefer the largest container in the player window during recovery so
  thumbnail-sized surfaces are never picked
- Add a watchdog misplaced-view check in MPVBackend that re-attaches
  the render view when it lives outside the visible player window
- Include container bounds and tracked-window flag in attach/decline
  logs
This commit is contained in:
Arkadiusz Fal
2026-06-22 19:55:32 +02:00
parent b82241564d
commit bf00c48e6c
3 changed files with 106 additions and 24 deletions

View File

@@ -2096,6 +2096,23 @@ extension MPVBackend: MPVClientDelegate {
previousVoDrops = voDrops ?? previousVoDrops
}
// Misplaced-view check: the player window is on screen but the
// shared render view lives in a different window (e.g. the mini
// capsule preview grabbed it during an expand race). Draws look
// healthy, yet the player window shows black.
if !self.isPiPActive,
let playerWindow = ExpandedPlayerWindowManager.shared.currentPlayerWindow,
playerWindow.isVisible,
let view = self._playerView, view.window !== playerWindow {
LoggingService.shared.warning(
"MPV render watchdog: player view outside visible player window (attach: \(view.attachmentDescription)) - re-attaching",
category: .mpv
)
if MPVContainerNSView.recoverSharedPlayerViewIfNeeded() {
self.resumeRendering()
}
}
#endif
// Gate on the verbose setting each tick (not at task start) so

View File

@@ -524,8 +524,17 @@ extension ExpandedPlayerWindowManager: NSWindowDelegate {
// Update navigation state
// Set collapsing first so mini player shows video immediately
appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
let navigationCoordinator = appEnvironment?.navigationCoordinator
navigationCoordinator?.isPlayerCollapsing = true
navigationCoordinator?.isPlayerExpanded = false
// Unlike hide(), this path has no animation completion to reset the
// flag. Left stuck true, the mini capsule mounts its video container
// forever and hijacks the shared render view on the next expand
// (player window stays black while the capsule renders the video).
Task { @MainActor in
navigationCoordinator?.isPlayerCollapsing = false
}
}
// Return false - we've already hidden the window with orderOut
return false

View File

@@ -189,6 +189,15 @@ final class MPVContainerNSView: NSView {
/// Callback when view is added to a window
var onDidMoveToWindow: (() -> Void)?
/// Whether this container lives in the player window tracked by
/// ExpandedPlayerWindowManager. That window is the primary video surface:
/// its containers may always claim the shared view, and nothing outside it
/// may steal from it while it is visible.
private var isInTrackedPlayerWindow: Bool {
guard let window else { return false }
return window === ExpandedPlayerWindowManager.shared.currentPlayerWindow
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
MPVContainerNSView.livingContainers.add(self)
@@ -220,12 +229,19 @@ final class MPVContainerNSView: NSView {
// 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 }
// A visible owner keeps the view unless we are in the tracked player
// window (the primary surface takes it even from the mini capsule
// preview, which otherwise renders the video at thumbnail size while
// the player window stays black). Same-window owners are left alone;
// SwiftUI layout updates settle those.
if let owner, ownerWindowVisible {
guard isInTrackedPlayerWindow, owner.window !== window else { return }
}
LoggingService.shared.debug(
"MPVContainerNSView[\(shortID)]: reclaiming stranded player view on window attach (previous owner: \(owner?.shortID ?? "none"), ownerWindowVisible: \(ownerWindowVisible))",
"MPVContainerNSView[\(shortID)]: reclaiming stranded player view on window attach (previous owner: \(owner?.shortID ?? "none"), ownerWindowVisible: \(ownerWindowVisible), trackedWindow: \(isInTrackedPlayerWindow))",
category: .mpv
)
setPlayerView(sharedView)
setPlayerView(sharedView, bypassingStealGuard: true)
}
override func viewWillMove(toSuperview newSuperview: NSView?) {
@@ -305,27 +321,43 @@ final 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 ownerWindow = owner.window,
ownerWindow.isVisible,
ownerWindow !== window {
// Refuse to steal 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). Exemption: containers in the tracked player window
// may claim the view before their window is visible it is the
// primary surface and is about to be shown.
if window?.isVisible != true, !isInTrackedPlayerWindow {
LoggingService.shared.debug(
"MPVContainerNSView[\(shortID)]: declined steal - view owned by visible container \(owner.shortID) (self window: \(window != nil), windowVisible: false, bounds: \(Int(bounds.width))x\(Int(bounds.height)))",
category: .mpv
)
return
}
// Conversely, never steal from the visible tracked player window:
// the mini capsule preview (mounted alongside it in separate-window
// mode) must not take the video out of the player window.
if ownerWindow === ExpandedPlayerWindowManager.shared.currentPlayerWindow {
LoggingService.shared.debug(
"MPVContainerNSView[\(shortID)]: declined steal - view owned by visible player window container \(owner.shortID) (self bounds: \(Int(bounds.width))x\(Int(bounds.height)))",
category: .mpv
)
return
}
}
let previousSuperview = playerView.superview.map { String(describing: type(of: $0)) } ?? "nil"
LoggingService.shared.debug(
"MPVContainerNSView[\(shortID)].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), bounds: \(Int(bounds.width))x\(Int(bounds.height)), trackedWindow: \(isInTrackedPlayerWindow))",
category: .mpv
)
@@ -356,19 +388,43 @@ final class MPVContainerNSView: NSView {
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 {
let trackedWindow = ExpandedPlayerWindowManager.shared.currentPlayerWindow
let trackedWindowVisible = trackedWindow?.isVisible == true
// Healthy when owned by a container in a visible window unless the
// tracked player window is on screen and the view sits outside it
// (e.g. the mini capsule rendering the video at thumbnail size while
// the player window shows black).
if owner?.window?.isVisible == true,
!trackedWindowVisible || owner?.window === trackedWindow {
return false
}
// Prefer the largest container in the tracked player window (skips
// thumbnail-sized surfaces that may share the window), then the
// largest in any visible window.
let candidates = livingContainers.allObjects
func area(_ container: MPVContainerNSView) -> CGFloat {
container.bounds.width * container.bounds.height
}
let target = candidates
.filter { trackedWindowVisible && $0.window === trackedWindow }
.max(by: { area($0) < area($1) })
?? candidates
.filter { $0.window?.isVisible == true }
.max(by: { area($0) < area($1) })
guard let target, target !== owner else {
LoggingService.shared.warning(
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: no visible container available (owner: \(owner?.shortID ?? "none"))",
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: no suitable container (owner: \(owner?.shortID ?? "none"), trackedWindowVisible: \(trackedWindowVisible))",
category: .mpv
)
return false
}
LoggingService.shared.warning(
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: re-attaching player view from \(owner?.shortID ?? "orphaned") to container \(target.shortID)",
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: re-attaching player view from \(owner?.shortID ?? "orphaned") to container \(target.shortID) (bounds: \(Int(target.bounds.width))x\(Int(target.bounds.height)), trackedWindow: \(target.window === trackedWindow))",
category: .mpv
)
target.setPlayerView(sharedView, bypassingStealGuard: true)