mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user