Files
yattee/Yattee/Views/Player/MPVRenderViewRepresentable.swift
Arkadiusz Fal 0d05daae9d 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
2026-06-08 22:55:31 +02:00

283 lines
12 KiB
Swift

//
// MPVRenderViewRepresentable.swift
// Yattee
//
// SwiftUI representable wrapper for MPVRenderView.
//
import SwiftUI
#if os(iOS) || os(tvOS)
import UIKit
/// UIViewRepresentable wrapper for MPVRenderView on iOS/tvOS.
/// Uses a container view to properly swap the player view when backend changes.
struct MPVRenderViewRepresentable: UIViewRepresentable {
let backend: MPVBackend
/// Optional player state to update PiP availability
var playerState: PlayerState?
func makeUIView(context: Context) -> UIView {
// Create a container that will hold the actual player view
let container = MPVContainerView()
container.backgroundColor = .black
MPVLogging.log("MPVRenderViewRepresentable.makeUIView: creating container",
details: "hasPlayerView:\(backend.playerView != nil)")
// Add the backend's player view
if let playerView = backend.playerView {
container.setPlayerView(playerView)
}
return container
}
func updateUIView(_ uiView: UIView, context: Context) {
MPVLogging.log("MPVRenderViewRepresentable.updateUIView",
details: "hasPlayerView:\(backend.playerView != nil)")
// Swap the player view if backend changed
if let container = uiView as? MPVContainerView,
let playerView = backend.playerView {
container.setPlayerView(playerView)
}
#if os(iOS)
// Set up PiP - backend handles window availability check internally
// and will complete setup via onDidMoveToWindow if needed
backend.setupPiPIfNeeded(in: uiView, playerState: playerState)
#endif
}
}
/// Container view that properly manages player view swapping
private class MPVContainerView: UIView {
private weak var currentPlayerView: UIView?
private let containerID = UUID()
// Track all living containers to enable view transfer on deinit
private static var livingContainers = NSHashTable<MPVContainerView>.weakObjects()
override init(frame: CGRect) {
super.init(frame: frame)
MPVContainerView.livingContainers.add(self)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
MPVContainerView.livingContainers.add(self)
}
deinit {
MPVLogging.log("MPVContainerView deinit",
details: "id:\(containerID.uuidString.prefix(8)) hasPlayerView:\(currentPlayerView != nil)")
// If we have the player view, transfer it to another living container
if let playerView = currentPlayerView {
// Find another container that's still alive and in the view hierarchy
for container in MPVContainerView.livingContainers.allObjects {
if container !== self && container.window != nil {
MPVLogging.log("MPVContainerView deinit: transferring player view to surviving container",
details: "from:\(containerID.uuidString.prefix(8)) to:\(container.containerID.uuidString.prefix(8))")
container.setPlayerView(playerView)
return
}
}
MPVLogging.warn("MPVContainerView deinit: no surviving container to transfer player view to!")
}
}
func setPlayerView(_ playerView: UIView) {
// Skip only if same view AND actually our subview
// (weak ref can point to a view that's been stolen by another container)
if playerView === currentPlayerView && playerView.superview === self {
MPVLogging.log("MPVContainerView.setPlayerView: same view, skipping")
return
}
// Check if player view is in another container
let isInAnotherContainer = playerView.superview is MPVContainerView && playerView.superview !== self
MPVLogging.log("MPVContainerView.setPlayerView: adding view",
details: "container:\(containerID.uuidString.prefix(8)) wasInOtherContainer:\(isInAnotherContainer) new:\(ObjectIdentifier(playerView))")
// Remove old view if present (different from the one we're adding)
if let oldView = currentPlayerView, oldView !== playerView {
MPVLogging.log("MPVContainerView: removing old view from superview")
oldView.removeFromSuperview()
}
// Add new view - this automatically removes it from its current superview
playerView.translatesAutoresizingMaskIntoConstraints = false
addSubview(playerView)
NSLayoutConstraint.activate([
playerView.topAnchor.constraint(equalTo: topAnchor),
playerView.bottomAnchor.constraint(equalTo: bottomAnchor),
playerView.leadingAnchor.constraint(equalTo: leadingAnchor),
playerView.trailingAnchor.constraint(equalTo: trailingAnchor)
])
currentPlayerView = playerView
}
}
#elseif os(macOS)
import AppKit
/// NSViewRepresentable wrapper for MPVRenderView on macOS.
/// Uses a container view to properly swap the player view when backend changes.
struct MPVRenderViewRepresentable: NSViewRepresentable {
let backend: MPVBackend
/// Optional player state to update PiP availability
var playerState: PlayerState?
func makeNSView(context: Context) -> NSView {
// Create a container that will hold the actual player view
let container = MPVContainerNSView()
container.wantsLayer = true
container.layer?.backgroundColor = NSColor.black.cgColor
// Add the backend's player view
if let playerView = backend.playerView {
container.setPlayerView(playerView)
}
// Set up callback for when view is added to window
// Use weak backend reference to avoid retaining during window destruction
container.onDidMoveToWindow = { [weak container, weak backend] in
guard let container, let backend else { return }
backend.setupPiPIfNeeded(in: container, playerState: playerState)
}
return container
}
func updateNSView(_ nsView: NSView, context: Context) {
// Swap the player view if backend changed
if let container = nsView as? MPVContainerNSView,
let playerView = backend.playerView {
container.setPlayerView(playerView)
}
// Try to set up PiP (will succeed when window is available)
backend.setupPiPIfNeeded(in: nsView, playerState: playerState)
}
}
/// 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)
if window != nil {
onDidMoveToWindow?()
}
}
override func viewWillMove(toSuperview newSuperview: NSView?) {
super.viewWillMove(toSuperview: newSuperview)
// When being removed from superview, detach the player view first for a clean
// teardown order during window destruction.
//
// The player view is a single SHARED instance (backend.playerView) that is
// re-parented between containers (mini bar expanded sheet). When presenting
// the sheet, the new container attaches the shared view (AppKit auto-removes it
// from the old container) BEFORE this old container is torn down. So by the time
// we get here, `currentPlayerView` may already live in another container. Only
// remove it if it still actually belongs to us otherwise we would rip the
// shared view out of its new home and blank the video (black screen on re-open,
// and a black mini-bar preview after collapse).
if newSuperview == nil {
if let playerView = currentPlayerView, playerView.superview === self {
// 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
}
}
/// 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
}
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
addSubview(playerView)
NSLayoutConstraint.activate([
playerView.topAnchor.constraint(equalTo: topAnchor),
playerView.bottomAnchor.constraint(equalTo: bottomAnchor),
playerView.leadingAnchor.constraint(equalTo: leadingAnchor),
playerView.trailingAnchor.constraint(equalTo: trailingAnchor)
])
currentPlayerView = playerView
}
}
#endif