// // 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.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.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