Files
plezy/macos/Runner/MpvPlayer/MpvPipController.swift
T
2026-08-10 20:28:41 +02:00

146 lines
4.1 KiB
Swift

import Cocoa
import QuartzCore
/// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject {
func pipWillStart()
func pipDidStart()
/// Called when PiP stops. `restored` is true if the user pressed the close button (restore UI).
func pipDidStop(restored: Bool)
/// Forward play/pause commands from PiP overlay to mpv
func pipSetPlaying(_ playing: Bool)
/// Query whether mpv is currently playing
var isPipPlaying: Bool { get }
}
/// Encapsulates macOS Picture-in-Picture using the private PIP.framework (PIPViewController).
/// This approach wraps the existing Metal rendering layer in PiP — no VO switching needed.
/// mpv continues rendering to its CAMetalLayer throughout PiP.
class MpvPipController: NSObject, PIPViewControllerDelegate {
// MARK: - Properties
private lazy var pip: PIPViewController = {
let vc = PIPViewController()
vc.delegate = self
return vc
}()
private var pipVideoVC: NSViewController?
private var pipVideoView: NSView?
weak var delegate: MpvPipDelegate?
private(set) var isActive = false
var autoPipEnabled = false
// Keep reference to the window for restore animation
private weak var sourceWindow: NSWindow?
// MARK: - Public API
static var isSupported: Bool { true }
/// Enter PiP by wrapping the given Metal layer in a view and presenting it.
/// The layer continues receiving mpv frames — no VO switch needed.
func startPip(metalLayer: CAMetalLayer, window: NSWindow, aspectRatio: NSSize) {
guard !isActive else { return }
sourceWindow = window
let videoView = NSView(frame: NSRect(origin: .zero, size: aspectRatio))
videoView.wantsLayer = true
videoView.layer = metalLayer
// Reset drawableSize to zero so it auto-derives from the layer's bounds.
// Without this, the explicit main-window drawableSize persists in PiP.
metalLayer.drawableSize = .zero
let vc = NSViewController()
vc.view = videoView
pipVideoVC = vc
pipVideoView = videoView
pip.playing = delegate?.isPipPlaying ?? false
pip.aspectRatio = aspectRatio
pip.replacementWindow = window
pip.replacementRect = window.contentView?.frame ?? .zero
delegate?.pipWillStart()
pip.presentAsPicture(inPicture: vc)
isActive = true
delegate?.pipDidStart()
}
func stopPip() {
guard isActive else { return }
pip.dismiss(pipVideoVC!)
}
/// Update the play/pause button state in the PiP overlay
func setPlaying(_ playing: Bool) {
pip.playing = playing
}
/// Update the aspect ratio (e.g., when video track changes)
func setAspectRatio(_ size: NSSize) {
pip.aspectRatio = size
}
func setAutoStart(_ enabled: Bool) {
autoPipEnabled = enabled
}
/// Clean up after PiP closes — detaches the Metal layer from the wrapper view
/// so MpvPlayerCore can re-add it to the main window.
/// Returns the Metal layer that was hosted in PiP.
@discardableResult
func detachLayer() -> CAMetalLayer? {
let metalLayer = pipVideoView?.layer as? CAMetalLayer
pipVideoView?.layer = CALayer() // detach before removing
pipVideoView = nil
pipVideoVC = nil
return metalLayer
}
// MARK: - PIPViewControllerDelegate
func pipShouldClose(_ pip: PIPViewController) -> Bool {
prepareForClose()
return true
}
func pipWillClose(_ pip: PIPViewController) {
prepareForClose()
}
func pipDidClose(_ pip: PIPViewController) {
isActive = false
delegate?.pipDidStop(restored: true)
}
func pipActionPlay(_ pip: PIPViewController) {
delegate?.pipSetPlaying(true)
}
func pipActionPause(_ pip: PIPViewController) {
delegate?.pipSetPlaying(false)
}
func pipActionStop(_ pip: PIPViewController) {
delegate?.pipSetPlaying(false)
}
// MARK: - Private
private func prepareForClose() {
guard let window = sourceWindow else { return }
pip.replacementWindow = window
pip.replacementRect = window.contentView?.frame ?? .zero
// Bring the main window forward for the restore animation
NSApp.activate(ignoringOtherApps: true)
window.deminiaturize(nil)
}
}