151 lines
4.4 KiB
Swift
151 lines
4.4 KiB
Swift
import Cocoa
|
|
|
|
/// 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 view 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
|
|
|
|
// Create a layer-hosting wrapper view for the Metal layer.
|
|
// PIPViewController resizes the view (and its root layer) as the PiP window resizes.
|
|
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 drawableSize set by updateFrame() (main window size)
|
|
// persists and causes mpv/MoltenVK to render at the wrong resolution in PiP.
|
|
metalLayer.drawableSize = .zero
|
|
|
|
// Create a view controller for PIPViewController
|
|
let vc = NSViewController()
|
|
vc.view = videoView
|
|
|
|
pipVideoVC = vc
|
|
pipVideoView = videoView
|
|
|
|
// Configure PiP
|
|
pip.playing = delegate?.isPipPlaying ?? false
|
|
pip.aspectRatio = aspectRatio
|
|
pip.replacementWindow = window
|
|
pip.replacementRect = window.contentView?.frame ?? .zero
|
|
|
|
delegate?.pipWillStart()
|
|
|
|
// Present PiP
|
|
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)
|
|
}
|
|
}
|