mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 14:52:03 +00:00
The macOS CAOpenGLLayer is pull-based: after the shared render view is re-parented into a reopened player window, a single forced repaint is issued. draw() consumed the forceDraw flag before validating the viewport, so when that repaint landed while the layer was still 0x0 (the expanded window builds its content a runloop late), the request was swallowed. With mpv paused during load, nothing repainted again, leaving the video permanently black with working audio. - Validate viewport/framebuffer in draw() before consuming flags; on failure keep the forced draw armed and retry (bounded, 5x50ms) - Repaint from setFrameSize on the first non-zero layout while a forced draw is still pending - Drop the stale fbo=1 fallback; render only into the validated live framebuffer binding - clearToBlack: take the CGL lock and skip glClear when no framebuffer is bound - clearing FBO 0 on this core-profile context latched GL_INVALID_FRAMEBUFFER_OPERATION, which libmpv reported on every load - Add diagnostics for dropped draws and the first-frame render
325 lines
9.6 KiB
Swift
325 lines
9.6 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 setFrameSize(_ newSize: NSSize) {
|
|
super.setFrameSize(newSize)
|
|
|
|
// If a forced draw was requested while the layer had no size/drawable
|
|
// (shared view re-parented into the deferred expanded sheet), repaint
|
|
// on the first non-zero layout. Gated on hasPendingForcedDraw so
|
|
// ordinary resizes don't trigger redundant redraws.
|
|
guard newSize.width > 0, newSize.height > 0,
|
|
let videoLayer, videoLayer.hasPendingForcedDraw else { return }
|
|
LoggingService.shared.debug("MPVOGLView: retrying pending forced draw after resize to \(newSize)", category: .mpv)
|
|
videoLayer.update(force: true)
|
|
}
|
|
|
|
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
|