mirror of
https://github.com/yattee/yattee.git
synced 2026-07-31 04:12:03 +00:00
Guard the deinit detach by superview so a container whose render view was stolen by a newer, not-yet-windowed container cannot rip the shared view out of it. Narrow the loading-gate heal to .ready only: the old video still playing during the details/streams fetch can emit .playing, which would open the gate to stale time updates and break the stale-error suppression in play()'s catch.
490 lines
23 KiB
Swift
490 lines
23 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!")
|
|
|
|
// Detach the shared view now instead of leaving it bound to this
|
|
// deallocating container: otherwise SwiftUI keeps reconciling a
|
|
// half-alive view and spins the main-thread trait update loop (100%
|
|
// CPU hang when opening a settings detail during playback, issue #956).
|
|
// Guard by superview: currentPlayerView is a stale weak ref when another
|
|
// (not yet windowed) container has stolen the view — don't rip it out.
|
|
if playerView.superview === self {
|
|
playerView.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
/// Internal (not private) so MPVBackend's render watchdog can call
|
|
/// `recoverSharedPlayerViewIfNeeded()`.
|
|
final class MPVContainerNSView: NSView {
|
|
private weak var currentPlayerView: NSView?
|
|
private let containerID = UUID()
|
|
|
|
/// Short identifier used in logs.
|
|
var shortID: String { String(containerID.uuidString.prefix(8)) }
|
|
|
|
/// 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()
|
|
|
|
/// The single shared render view most recently attached to any container.
|
|
/// Lets a container that gains a window reclaim the view when it was
|
|
/// orphaned or left behind in a non-visible window.
|
|
private static weak var sharedPlayerView: 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)
|
|
}
|
|
|
|
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?()
|
|
reclaimSharedPlayerViewIfStranded()
|
|
}
|
|
}
|
|
|
|
/// When this container gains a window, adopt the shared player view if it
|
|
/// is currently orphaned or parented in a container whose window is gone
|
|
/// or not visible. This is the recovery path for the expand race where the
|
|
/// mini capsule unmounts before any windowed container exists ("no
|
|
/// transfer target"), and for a view left behind in a stale ordered-out
|
|
/// player window.
|
|
private func reclaimSharedPlayerViewIfStranded() {
|
|
guard let sharedView = Self.sharedPlayerView, sharedView.superview !== self else { return }
|
|
let owner = sharedView.superview as? MPVContainerNSView
|
|
// Parented outside any container (e.g. mid-transfer) — leave it alone.
|
|
if sharedView.superview != nil, owner == nil { return }
|
|
let ownerWindowVisible = owner?.window?.isVisible == true
|
|
// 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), trackedWindow: \(isInTrackedPlayerWindow))",
|
|
category: .mpv
|
|
)
|
|
setPlayerView(sharedView, bypassingStealGuard: true)
|
|
}
|
|
|
|
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[\(shortID)]: unmounting - transferring player view to container \(target.shortID) (windowVisible: \(target.window?.isVisible == true))",
|
|
category: .mpv
|
|
)
|
|
// The transfer is initiated by the current owner, so bypass
|
|
// the steal guard (the target may legitimately be in a
|
|
// not-yet-visible window, e.g. hidden for PiP).
|
|
target.setPlayerView(playerView, bypassingStealGuard: true)
|
|
} else {
|
|
LoggingService.shared.debug(
|
|
"MPVContainerNSView[\(shortID)]: unmounting - no transfer target, parking player view (reclaimed when a container gains a window)",
|
|
category: .mpv
|
|
)
|
|
playerView.removeFromSuperview()
|
|
MPVContainerNSView.scheduleSharedViewAdoptionRetry()
|
|
}
|
|
}
|
|
currentPlayerView = nil
|
|
onDidMoveToWindow = nil
|
|
}
|
|
}
|
|
|
|
/// Another living container that can host the shared player view.
|
|
/// Ranking:
|
|
/// 1. A container whose window is currently visible.
|
|
/// 2. A container in the window still tracked by ExpandedPlayerWindowManager
|
|
/// (mid-presentation or hidden for PiP — it will become visible).
|
|
/// 3. nil — park the view rather than parenting it in a stale ordered-out
|
|
/// window that CoreAnimation never composites (permanent black video);
|
|
/// `reclaimSharedPlayerViewIfStranded` re-adopts it when a container
|
|
/// gains a window.
|
|
private static func findTransferTarget(excluding source: MPVContainerNSView) -> MPVContainerNSView? {
|
|
let candidates = livingContainers.allObjects.filter { $0 !== source && $0.window != nil }
|
|
if let visible = candidates.first(where: { $0.window?.isVisible == true }) {
|
|
return visible
|
|
}
|
|
if let trackedWindow = ExpandedPlayerWindowManager.shared.currentPlayerWindow,
|
|
let tracked = candidates.first(where: { $0.window === trackedWindow }) {
|
|
LoggingService.shared.debug(
|
|
"MPVContainerNSView.findTransferTarget: no visible candidate, using container \(tracked.shortID) in tracked player window",
|
|
category: .mpv
|
|
)
|
|
return tracked
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setPlayerView(_ playerView: NSView, bypassingStealGuard: Bool = false) {
|
|
// 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
|
|
}
|
|
|
|
if !bypassingStealGuard,
|
|
let owner = playerView.superview as? MPVContainerNSView,
|
|
owner !== self,
|
|
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), bounds: \(Int(bounds.width))x\(Int(bounds.height)), trackedWindow: \(isInTrackedPlayerWindow))",
|
|
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
|
|
MPVContainerNSView.sharedPlayerView = playerView
|
|
}
|
|
|
|
/// True while an adoption retry loop is running - avoids stacking loops
|
|
/// when several triggers fire in quick succession.
|
|
private static var adoptionRetryActive = false
|
|
|
|
/// Watch the shared player view for ~1s and re-home it as soon as it
|
|
/// needs it (parked with no owner, or owned by a container whose window
|
|
/// lost visibility). This covers transitions with no container lifecycle
|
|
/// hook to react to:
|
|
/// - parking while the next host's window exists but is not yet visible
|
|
/// (sheet mid-presentation: viewDidMoveToWindow already fired and
|
|
/// findTransferTarget rejects non-visible windows)
|
|
/// - sheet dismissal, where the dismissed sheet's hierarchy stays alive
|
|
/// holding the view inside a now-invisible window
|
|
/// Without this the video stays black until the render watchdog (~10s).
|
|
static func scheduleSharedViewAdoptionRetry() {
|
|
guard !adoptionRetryActive else { return }
|
|
adoptionRetryActive = true
|
|
adoptionRetryTick(attempt: 0)
|
|
}
|
|
|
|
private static func adoptionRetryTick(attempt: Int) {
|
|
guard attempt < 20 else {
|
|
adoptionRetryActive = false
|
|
return
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
guard sharedPlayerView != nil else {
|
|
adoptionRetryActive = false
|
|
return
|
|
}
|
|
// quiet: while playback is tearing down there legitimately is no
|
|
// container to adopt the view - not worth a warning per tick.
|
|
if recoverSharedPlayerViewIfNeeded(quiet: true) {
|
|
adoptionRetryActive = false
|
|
(sharedPlayerView as? MPVOGLView)?.resumeRendering()
|
|
} else {
|
|
adoptionRetryTick(attempt: attempt + 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Re-attach the shared player view to a container in a visible window when
|
|
/// it is currently orphaned or parented somewhere non-visible. Called by
|
|
/// MPVBackend's render watchdog when video output stalls (frames consumed
|
|
/// without a single draw). Returns true when the view was re-parented.
|
|
@discardableResult
|
|
static func recoverSharedPlayerViewIfNeeded(quiet: Bool = false) -> Bool {
|
|
guard let sharedView = sharedPlayerView else { return false }
|
|
let owner = sharedView.superview as? MPVContainerNSView
|
|
// Parented outside any container — not ours to manage.
|
|
if sharedView.superview != nil, owner == nil { return false }
|
|
|
|
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 {
|
|
if !quiet {
|
|
LoggingService.shared.warning(
|
|
"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) (bounds: \(Int(target.bounds.width))x\(Int(target.bounds.height)), trackedWindow: \(target.window === trackedWindow))",
|
|
category: .mpv
|
|
)
|
|
target.setPlayerView(sharedView, bypassingStealGuard: true)
|
|
return sharedView.superview === target
|
|
}
|
|
}
|
|
|
|
#endif
|