mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 23:02:04 +00:00
The player video surface is a single shared MPVOGLView that is re-parented between the mini-bar container (main window) and the expanded-player sheet (a separate sheet window). When presenting the sheet, the new container attaches the shared view before the old mini-bar container tears down, so the old container's viewWillMove(toSuperview: nil) was ripping the shared view out of its new home via removeFromSuperview() — leaving it with no window and a black surface. Symmetric, so it also blanked the mini-bar preview after collapsing. Only detach the shared view if it still belongs to the container (superview === self). Also force a repaint on window reattach so a paused frame redraws, and extend the onAppear resumeRendering to macOS.
312 lines
8.9 KiB
Swift
312 lines
8.9 KiB
Swift
//
|
|
// MPVOGLView.swift
|
|
// Yattee
|
|
//
|
|
// NSView that hosts MPVOpenGLLayer and manages CADisplayLink for macOS.
|
|
// Moves all rendering off the main thread for smooth UI during video playback.
|
|
//
|
|
|
|
#if os(macOS)
|
|
|
|
import AppKit
|
|
import CoreMedia
|
|
import CoreVideo
|
|
import Libmpv
|
|
|
|
// MARK: - MPVOGLView
|
|
|
|
/// View for MPV video rendering on macOS.
|
|
/// Hosts an MPVOpenGLLayer and manages CADisplayLink for vsync timing.
|
|
final class MPVOGLView: NSView {
|
|
// MARK: - Properties
|
|
|
|
/// The OpenGL layer that handles rendering.
|
|
/// `nil` when OpenGL is unavailable (e.g. a GPU-less virtual machine); the
|
|
/// view then renders nothing and `setup(with:)` throws instead of crashing.
|
|
private(set) var videoLayer: MPVOpenGLLayer?
|
|
|
|
/// Reference to the MPV client.
|
|
private weak var mpvClient: MPVClient?
|
|
|
|
/// CADisplayLink for frame timing and vsync (macOS 14+).
|
|
private var displayLink: CADisplayLink?
|
|
|
|
/// Whether the view has been uninitialized.
|
|
private var isUninited = false
|
|
|
|
/// Lock for thread-safe access to isUninited.
|
|
private let uninitLock = NSLock()
|
|
|
|
// MARK: - First Frame Tracking
|
|
|
|
/// Tracks whether MPV has signaled it has a frame ready to render.
|
|
var mpvHasFrameReady = false
|
|
|
|
/// Callback when first frame is rendered.
|
|
var onFirstFrameRendered: (() -> Void)? {
|
|
get { videoLayer?.onFirstFrameRendered }
|
|
set { videoLayer?.onFirstFrameRendered = newValue }
|
|
}
|
|
|
|
// MARK: - Video Info
|
|
|
|
/// Video frame rate from MPV (for debug overlay).
|
|
var videoFPS: Double = 60.0
|
|
|
|
/// Actual display link frame rate.
|
|
var displayLinkActualFPS: Double = 60.0
|
|
|
|
/// Current display link target frame rate (for debug overlay).
|
|
var displayLinkTargetFPS: Double {
|
|
displayLinkActualFPS
|
|
}
|
|
|
|
// MARK: - PiP Properties (forwarded to layer)
|
|
|
|
/// Whether to capture frames for PiP.
|
|
var captureFramesForPiP: Bool {
|
|
get { videoLayer?.captureFramesForPiP ?? false }
|
|
set { videoLayer?.captureFramesForPiP = newValue }
|
|
}
|
|
|
|
/// Whether PiP is currently active.
|
|
var isPiPActive: Bool {
|
|
get { videoLayer?.isPiPActive ?? false }
|
|
set { videoLayer?.isPiPActive = newValue }
|
|
}
|
|
|
|
/// Callback when a new frame is ready for PiP.
|
|
var onFrameReady: ((CVPixelBuffer, CMTime) -> Void)? {
|
|
get { videoLayer?.onFrameReady }
|
|
set { videoLayer?.onFrameReady = newValue }
|
|
}
|
|
|
|
/// Video content width (actual video dimensions for letterbox cropping).
|
|
var videoContentWidth: Int {
|
|
get { videoLayer?.videoContentWidth ?? 0 }
|
|
set { videoLayer?.videoContentWidth = newValue }
|
|
}
|
|
|
|
/// Video content height (actual video dimensions for letterbox cropping).
|
|
var videoContentHeight: Int {
|
|
get { videoLayer?.videoContentHeight ?? 0 }
|
|
set { videoLayer?.videoContentHeight = newValue }
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
commonInit()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
commonInit()
|
|
}
|
|
|
|
/// Convenience initializer with zero frame.
|
|
convenience init() {
|
|
self.init(frame: .zero)
|
|
}
|
|
|
|
private func commonInit() {
|
|
// Set up layer-backed view
|
|
wantsLayer = true
|
|
|
|
// Create the OpenGL layer. This can fail on machines without a usable
|
|
// GPU (e.g. virtual machines), in which case the view renders nothing
|
|
// and setup(with:) will throw rather than crashing the app at launch.
|
|
if let videoLayer = MPVOpenGLLayer(videoView: self) {
|
|
self.videoLayer = videoLayer
|
|
layer = videoLayer
|
|
videoLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
|
|
} else {
|
|
LoggingService.shared.error("MPVOGLView: OpenGL unavailable - video rendering disabled", category: .mpv)
|
|
}
|
|
|
|
// Configure view properties
|
|
autoresizingMask = [.width, .height]
|
|
}
|
|
|
|
deinit {
|
|
uninit()
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
/// Set up with an MPV client.
|
|
func setup(with client: MPVClient) throws {
|
|
self.mpvClient = client
|
|
|
|
// No layer means OpenGL was unavailable at init (e.g. GPU-less VM).
|
|
guard let videoLayer else {
|
|
throw MPVRenderError.openGLSetupFailed
|
|
}
|
|
|
|
// Set up the layer
|
|
try videoLayer.setup(with: client)
|
|
|
|
// Start display link
|
|
startDisplayLink()
|
|
}
|
|
|
|
/// Async setup variant.
|
|
func setupAsync(with client: MPVClient) async throws {
|
|
try setup(with: client)
|
|
}
|
|
|
|
// MARK: - View Lifecycle
|
|
|
|
override var isOpaque: Bool { true }
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
|
|
if let window {
|
|
// Recreate display link for new window
|
|
stopDisplayLink()
|
|
startDisplayLink()
|
|
|
|
// Update contents scale for new window
|
|
videoLayer?.contentsScale = window.backingScaleFactor
|
|
|
|
// Reattaching (e.g. macOS player sheet dismissed via ESC then re-expanded)
|
|
// detaches this shared view without stopping playback. macOS drawing is
|
|
// pull-based, so force a redraw to repaint the current frame — otherwise
|
|
// the layer stays black until MPV emits a new frame (never, if paused).
|
|
resumeRendering()
|
|
}
|
|
}
|
|
|
|
override func viewDidChangeBackingProperties() {
|
|
super.viewDidChangeBackingProperties()
|
|
|
|
// Update contents scale when backing properties change
|
|
if let scale = window?.backingScaleFactor {
|
|
videoLayer?.contentsScale = scale
|
|
}
|
|
|
|
// Update display refresh rate
|
|
updateDisplayRefreshRate()
|
|
}
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
// No-op - the layer handles all drawing
|
|
}
|
|
|
|
// MARK: - CADisplayLink Management
|
|
|
|
func startDisplayLink() {
|
|
guard displayLink == nil else { return }
|
|
|
|
// Create display link using modern API (macOS 14+)
|
|
displayLink = displayLink(target: self, selector: #selector(displayLinkFired(_:)))
|
|
displayLink?.add(to: .main, forMode: .common)
|
|
|
|
// Update refresh rate info
|
|
updateDisplayRefreshRate()
|
|
|
|
LoggingService.shared.debug("MPVOGLView: display link started", category: .mpv)
|
|
}
|
|
|
|
func stopDisplayLink() {
|
|
displayLink?.invalidate()
|
|
displayLink = nil
|
|
|
|
LoggingService.shared.debug("MPVOGLView: display link stopped", category: .mpv)
|
|
}
|
|
|
|
@objc private func displayLinkFired(_ sender: CADisplayLink) {
|
|
// Check if uninited (thread-safe)
|
|
uninitLock.lock()
|
|
let uninited = isUninited
|
|
uninitLock.unlock()
|
|
|
|
guard !uninited else { return }
|
|
|
|
// Report frame swap to MPV for vsync timing
|
|
mpvClient?.reportSwap()
|
|
}
|
|
|
|
/// Update display link for the current display.
|
|
func updateDisplayLink() {
|
|
// With CADisplayLink, we just need to update the refresh rate info
|
|
updateDisplayRefreshRate()
|
|
}
|
|
|
|
/// Update the cached display refresh rate.
|
|
private func updateDisplayRefreshRate() {
|
|
guard let screen = window?.screen else {
|
|
displayLinkActualFPS = 60.0
|
|
return
|
|
}
|
|
|
|
// Get refresh rate from screen
|
|
displayLinkActualFPS = Double(screen.maximumFramesPerSecond)
|
|
if displayLinkActualFPS <= 0 {
|
|
displayLinkActualFPS = 60.0
|
|
}
|
|
|
|
LoggingService.shared.debug("MPVOGLView: display refresh rate: \(displayLinkActualFPS) Hz", category: .mpv)
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Reset first frame tracking (call when loading new content).
|
|
func resetFirstFrameTracking() {
|
|
mpvHasFrameReady = false
|
|
videoLayer?.resetFirstFrameTracking()
|
|
}
|
|
|
|
/// Clear the view to black.
|
|
func clearToBlack() {
|
|
videoLayer?.clearToBlack()
|
|
}
|
|
|
|
/// Pause rendering.
|
|
func pauseRendering() {
|
|
// For now, just stop triggering updates
|
|
// The layer will still respond to explicit update() calls
|
|
}
|
|
|
|
/// Resume rendering.
|
|
func resumeRendering() {
|
|
videoLayer?.update(force: true)
|
|
}
|
|
|
|
/// Update cached time position for PiP timestamps.
|
|
func updateTimePosition(_ time: Double) {
|
|
videoLayer?.updateTimePosition(time)
|
|
}
|
|
|
|
/// Clear the main view for PiP transition (stub for now).
|
|
func clearMainViewForPiP() {
|
|
clearToBlack()
|
|
}
|
|
|
|
/// Update PiP target render size - forces recreation of PiP capture resources.
|
|
func updatePiPTargetSize(_ size: CMVideoDimensions) {
|
|
videoLayer?.updatePiPTargetSize(size)
|
|
}
|
|
|
|
// MARK: - Cleanup
|
|
|
|
/// Uninitialize the view and release resources.
|
|
func uninit() {
|
|
uninitLock.lock()
|
|
defer { uninitLock.unlock() }
|
|
|
|
guard !isUninited else { return }
|
|
isUninited = true
|
|
|
|
stopDisplayLink()
|
|
videoLayer?.uninit()
|
|
|
|
// Note: onFirstFrameRendered and onFrameReady are forwarded to videoLayer,
|
|
// and videoLayer.uninit() clears them
|
|
}
|
|
}
|
|
|
|
#endif
|