Files
yattee/Yattee/Services/Player/MPV/MPVOGLView.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

345 lines
11 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
import QuartzCore
// 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
// 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. Disable implicit actions so
// Core Animation never builds a presentation copy of the GL layer
// for this change (crashes on macOS 27 beta).
CATransaction.begin()
CATransaction.setDisableActions(true)
videoLayer?.contentsScale = window.backingScaleFactor
CATransaction.commit()
// 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. Disable implicit
// actions so Core Animation never builds a presentation copy of the GL
// layer for this change (crashes on macOS 27 beta).
if let scale = window?.backingScaleFactor {
CATransaction.begin()
CATransaction.setDisableActions(true)
videoLayer?.contentsScale = scale
CATransaction.commit()
}
// 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 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()
}
/// Thread-safe snapshot of the layer's render-health counters.
func renderHealthSnapshot() -> MPVRenderHealth? {
videoLayer?.renderHealthSnapshot()
}
/// Human-readable description of where this shared view is currently
/// attached, for render-health diagnostics. Main thread only.
var attachmentDescription: String {
let superviewDesc: String
if let container = superview as? MPVContainerNSView {
superviewDesc = "container=\(container.shortID)"
} else if let superview {
superviewDesc = "superview=\(type(of: superview))"
} else {
superviewDesc = "superview=nil"
}
let windowDesc = window.map { "window=#\($0.windowNumber) visible=\($0.isVisible)" } ?? "window=nil"
return "\(superviewDesc) \(windowDesc) bounds=\(Int(bounds.width))x\(Int(bounds.height))"
}
/// 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