mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
294 lines
7.8 KiB
Swift
294 lines
7.8 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.
|
|
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
|