Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,293 @@
//
// 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.
private(set) lazy var videoLayer: MPVOpenGLLayer = {
MPVOpenGLLayer(videoView: self)
}()
/// 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 }
set { videoLayer.captureFramesForPiP = newValue }
}
/// Whether PiP is currently active.
var isPiPActive: Bool {
get { videoLayer.isPiPActive }
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 }
set { videoLayer.videoContentWidth = newValue }
}
/// Video content height (actual video dimensions for letterbox cropping).
var videoContentHeight: Int {
get { videoLayer.videoContentHeight }
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
layer = videoLayer
// Configure layer properties
videoLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
// 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
// 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
}
}
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