Files
yattee/Yattee/Services/Player/MPV/MPVOGLView.swift
Arkadiusz Fal 5c8c55f41c Fix macOS black video after player close/reopen
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
2026-06-03 22:00:28 +02:00

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