Fix black screen after restoring PiP when player window was closed on macOS

The expanded player window hidden for PiP is reused on restore, so its
MPVContainerNSView kept a stale weak reference to the shared render view
that the mini player preview had taken during PiP. On restore the mini
preview released the view as an orphan and the reused container skipped
re-attaching it because its guard only compared view identity.

- Skip re-attach in setPlayerView only when the view is still actually
  our subview, matching the iOS container's stolen-view handling
- Transfer the shared render view to another living container on unmount
  instead of orphaning it, since SwiftUI is not guaranteed to re-run
  updateNSView on a hidden window's content after restore
- Force a repaint when restoring the window hidden for PiP; forced draws
  requested while ordered out are dropped and the layer is pull-based
This commit is contained in:
Arkadiusz Fal
2026-06-08 22:55:31 +02:00
parent 0fa4df161b
commit 0d05daae9d
3 changed files with 71 additions and 6 deletions

View File

@@ -1290,6 +1290,12 @@ final class MPVBackend: PlayerBackend {
self?.onPiPDidStart?()
} else {
self?._playerView?.captureFramesForPiP = false
// The layer was cleared to black when PiP started and macOS
// drawing is pull-based force a repaint so the restored
// window shows the current frame even while paused. If the
// view isn't in a window yet, the forced draw stays armed and
// the setFrameSize/viewDidMoveToWindow retries complete it.
self?._playerView?.resumeRendering()
}
}

View File

@@ -69,6 +69,11 @@ final class ExpandedPlayerWindowManager: NSObject {
existingWindow.alphaValue = 1
existingWindow.makeKeyAndOrderFront(nil)
}
// Any forced draw requested while the window was ordered out was
// dropped (no drawable); the layer is pull-based, so repaint now
// that the window is on screen otherwise a paused video stays
// black until the next MPV frame (never, while paused).
(appEnvironment.playerService.currentBackend as? MPVBackend)?.resumeRendering()
return
}

View File

@@ -170,10 +170,25 @@ struct MPVRenderViewRepresentable: NSViewRepresentable {
/// Container view that properly manages player view swapping on macOS
private class MPVContainerNSView: NSView {
private weak var currentPlayerView: NSView?
private let containerID = UUID()
/// 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()
/// Callback when view is added to a window
var onDidMoveToWindow: (() -> Void)?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
MPVContainerNSView.livingContainers.add(self)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
MPVContainerNSView.livingContainers.add(self)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
// Notify when we're added to a window (not removed)
@@ -197,19 +212,58 @@ private class MPVContainerNSView: NSView {
// and a black mini-bar preview after collapse).
if newSuperview == nil {
if let playerView = currentPlayerView, playerView.superview === self {
playerView.removeFromSuperview()
// Hand the shared view to another living container that still has a
// window instead of orphaning it. SwiftUI is not guaranteed to re-run
// updateNSView on the other container (e.g. the expanded player window
// hidden for PiP re-shown on restore), so without this transfer the
// 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))",
category: .mpv
)
target.setPlayerView(playerView)
} else {
LoggingService.shared.debug(
"MPVContainerNSView[\(containerID.uuidString.prefix(8))]: unmounting - no transfer target, removing player view",
category: .mpv
)
playerView.removeFromSuperview()
}
}
currentPlayerView = nil
onDidMoveToWindow = nil
}
}
func setPlayerView(_ playerView: NSView) {
// Skip if same view
guard playerView !== currentPlayerView else { return }
/// 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).
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
}
// Remove old view if present
currentPlayerView?.removeFromSuperview()
func setPlayerView(_ playerView: NSView) {
// 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
// expanded window is hidden; on restore the view must be re-claimed
// here or it stays orphaned and the window shows only black.
if playerView === currentPlayerView && playerView.superview === self {
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))",
category: .mpv
)
// Remove old view if it's a different view that still belongs to us
if let oldView = currentPlayerView, oldView !== playerView, oldView.superview === self {
oldView.removeFromSuperview()
}
// Add new view
playerView.translatesAutoresizingMaskIntoConstraints = false