feat(apple): use AVFoundation mpv output

This commit is contained in:
edde746
2026-05-08 02:39:42 +02:00
parent 2208d6c637
commit cedd448ef2
24 changed files with 216 additions and 328 deletions
+1 -1
View File
@@ -781,7 +781,7 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = 29803ccbfa631997d496f34c2c1ed191cc8fd2c3;
revision = 8aa5e1abb68fffe1d29511da9e02feca575c9311;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3"
"revision" : "8aa5e1abb68fffe1d29511da9e02feca575c9311"
}
}
],
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3"
"revision" : "8aa5e1abb68fffe1d29511da9e02feca575c9311"
}
}
],
+16 -104
View File
@@ -1,3 +1,4 @@
import AVFoundation
import AVKit
import UIKit
@@ -22,11 +23,7 @@ import UIKit
weak var delegate: MpvPipDelegate?
var isPipActive: Bool { false }
var autoStartEnabled: Bool { false }
var layerPointer: UnsafeMutableRawPointer {
// Return a dummy non-null pointer layerPointer is handed to mpv for
// rendering into PiP, which never activates on tvOS.
UnsafeMutableRawPointer(bitPattern: 0x1)!
}
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { super.init() }
func setup(with layer: CALayer, containerView: UIView) {}
func setAutoStart(_ enabled: Bool) {}
func warmLayer(currentTime: Double, isPlaying: Bool) {}
@@ -67,18 +64,13 @@ import UIKit
// MARK: - Properties
private var pipController: AVPictureInPictureController?
private let sampleBufferLayer = AVSampleBufferDisplayLayer()
private var containerView: UIView?
private weak var sampleBufferLayer: AVSampleBufferDisplayLayer?
weak var delegate: MpvPipDelegate?
/// Pointer to the sample buffer layer for passing to mpv as `wid`
var layerPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(sampleBufferLayer).toOpaque()
}
// MARK: - Initialization
override init() {
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
self.sampleBufferLayer = sampleBufferDisplayLayer
super.init()
setup()
}
@@ -93,18 +85,6 @@ import UIKit
print("[MpvPipController] Failed to configure audio session: \(error)")
}
// The sample buffer layer must be in a visible view hierarchy for
// isPictureInPicturePossible to become true.
if let window = ExternalDisplayManager.mainApplicationWindow() {
let view = UIView(frame: window.bounds)
view.clipsToBounds = true
view.isUserInteractionEnabled = false
sampleBufferLayer.frame = view.bounds
view.layer.addSublayer(sampleBufferLayer)
window.addSubview(view)
containerView = view
}
createPipController()
}
@@ -112,7 +92,7 @@ import UIKit
private var delegateHelper: AnyObject?
private func createPipController() {
guard #available(iOS 15.0, *) else { return }
guard #available(iOS 15.0, *), let sampleBufferLayer else { return }
let helper = PipDelegateHelper(controller: self)
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: sampleBufferLayer,
@@ -132,82 +112,23 @@ import UIKit
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
}
/// Push a black frame to the sample buffer layer so PiP has content
/// to display immediately (before vo_pip decodes the first real frame).
/// vo_avfoundation renders into the inline display layer, so PiP reuses
/// the current video frame instead of pushing synthetic buffers here.
func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {
var pixelBuffer: CVPixelBuffer?
let attrs: [String: Any] = [
kCVPixelBufferIOSurfacePropertiesKey as String: [:]
]
let status = CVPixelBufferCreate(
kCFAllocatorDefault, Int(width), Int(height),
kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer
)
guard status == kCVReturnSuccess, let pb = pixelBuffer else { return }
// Fill with black
CVPixelBufferLockBaseAddress(pb, [])
if let base = CVPixelBufferGetBaseAddress(pb) {
memset(base, 0, CVPixelBufferGetDataSize(pb))
}
CVPixelBufferUnlockBaseAddress(pb, [])
// Use current timebase time for PTS (if available) so the frame isn't stale
let pts: CMTime
if let tb = sampleBufferLayer.controlTimebase {
pts = CMTimebaseGetTime(tb)
} else {
pts = CMTime(value: 0, timescale: 30)
}
var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30),
presentationTimeStamp: pts,
decodeTimeStamp: .invalid
)
// Create format description and sample buffer
var formatDesc: CMVideoFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescriptionOut: &formatDesc
)
guard let fmt = formatDesc else { return }
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescription: fmt,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
)
guard let sb = sampleBuffer else { return }
// Set DisplayImmediately so it shows regardless of timebase timing
if let attachments = CMSampleBufferGetSampleAttachmentsArray(
sb, createIfNecessary: true) as? [NSMutableDictionary],
let dict = attachments.first
{
dict[kCMSampleAttachmentKey_DisplayImmediately] = true
}
sampleBufferLayer.enqueue(sb)
}
/// Sync the layer's controlTimebase with the actual playback position.
/// This makes the PiP progress bar show the correct time.
func syncTimebase(currentTime: Double, isPlaying: Bool) {
guard let timebase = sampleBufferLayer.controlTimebase else { return }
guard let timebase = sampleBufferLayer?.controlTimebase else { return }
let cmTime = CMTime(seconds: currentTime, preferredTimescale: 1000)
CMTimebaseSetTime(timebase, time: cmTime)
CMTimebaseSetRate(timebase, rate: isPlaying ? 1.0 : 0.0)
}
/// Ensure the layer has a timebase and blank frame so the system considers
/// PiP possible (required for canStartPictureInPictureAutomaticallyFromInline).
/// Ensure the layer has a timebase so the PiP progress UI can follow mpv.
func warmLayer(currentTime: Double, isPlaying: Bool) {
guard let sampleBufferLayer else { return }
if sampleBufferLayer.controlTimebase == nil {
var timebase: CMTimebase?
CMTimebaseCreateWithSourceClock(
@@ -220,7 +141,6 @@ import UIKit
}
}
syncTimebase(currentTime: currentTime, isPlaying: isPlaying)
pushBlankFrame()
}
// MARK: - Public API
@@ -241,19 +161,19 @@ import UIKit
var attempts = 0
func tryStart() {
let possible = pipController.isPictureInPicturePossible
let hasTimebase = sampleBufferLayer.controlTimebase != nil
let hasTimebase = self.sampleBufferLayer?.controlTimebase != nil
let hasFrame: Bool
if !waitForFrame {
hasFrame = true // Skip frame check for auto-PiP
} else if #available(iOS 17.4, *) {
hasFrame = sampleBufferLayer.isReadyForDisplay
hasFrame = self.sampleBufferLayer?.isReadyForDisplay ?? false
} else {
hasFrame = true
}
if possible && hasTimebase && hasFrame {
print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP")
print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP")
pipController.startPictureInPicture()
completion(true)
} else if attempts < 40 {
@@ -278,9 +198,7 @@ import UIKit
pipController?.invalidatePlaybackState()
}
/// Fully tear down PiP removes the container view from the window and
/// destroys the AVPictureInPictureController so the system can no longer
/// trigger auto-PiP after the player is disposed.
/// Fully tear down PiP without touching the shared inline display layer.
func teardown() {
pipController?.stopPictureInPicture()
if #available(iOS 14.2, *) {
@@ -288,16 +206,10 @@ import UIKit
}
pipController = nil
delegateHelper = nil
sampleBufferLayer.flushAndRemoveImage()
sampleBufferLayer.controlTimebase = nil
sampleBufferLayer.removeFromSuperlayer()
containerView?.removeFromSuperview()
containerView = nil
}
/// Flush enqueued sample buffers from the layer to free video frame memory
/// PiP shares the inline display layer, so cleanup must not flush it.
func flushLayer() {
sampleBufferLayer.flushAndRemoveImage()
}
}
+18 -56
View File
@@ -1,7 +1,7 @@
import Libmpv
import AVFoundation
import UIKit
/// Core MPV player using Metal rendering for iOS.
/// Core MPV player using AVFoundation sample-buffer rendering for iOS/tvOS.
class MpvPlayerCore: MpvPlayerCoreBase {
private var containerView: UIView?
@@ -23,15 +23,15 @@ class MpvPlayerCore: MpvPlayerCoreBase {
container.backgroundColor = .clear
container.isUserInteractionEnabled = false
let layer = MpvMetalLayer()
let layer = MpvVideoLayer()
layer.frame = container.bounds
layer.contentsScale = window.screen.nativeScale
layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor
layer.videoGravity = .resizeAspect
container.layer.addSublayer(layer)
containerView = container
metalLayer = layer
videoLayer = layer
window.insertSubview(container, at: 0)
@@ -39,7 +39,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer()
container.removeFromSuperview()
metalLayer = nil
videoLayer = nil
containerView = nil
return false
}
@@ -54,40 +54,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
return true
}
func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool {
guard mpv != nil else { return false }
print("[MpvPlayerCore] Switching to pip VO for PiP")
metalLayer?.removeFromSuperlayer()
setProperty("vid", value: "no")
setInt64PropertyAsync("wid", value: Int64(Int(bitPattern: layerPtr))) { _ in }
setProperty("vo", value: "pip")
setProperty("vid", value: "auto")
print("[MpvPlayerCore] Switched to pip VO successfully")
return true
}
func switchToGpuNextVO() -> Bool {
guard mpv != nil, let metalLayer else { return false }
print("[MpvPlayerCore] Switching back to gpu-next VO")
setProperty("vid", value: "no")
setInt64PropertyAsync("wid", value: Int64(Int(bitPattern: Unmanaged.passUnretained(metalLayer).toOpaque()))) { _ in
}
applyGpuNextOptions()
setProperty("vid", value: "auto")
if metalLayer.superlayer == nil, let containerView {
containerView.layer.addSublayer(metalLayer)
}
print("[MpvPlayerCore] Switched back to gpu-next VO successfully")
return true
}
var sampleBufferDisplayLayer: MpvVideoLayer? { videoLayer }
func setVisible(_ visible: Bool) {
guard let containerView else { return }
@@ -99,28 +66,24 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
func updateFrame(_ frame: CGRect? = nil) {
guard let metalLayer, let containerView else { return }
guard let videoLayer, let containerView else { return }
if let frame {
containerView.frame = frame
metalLayer.frame = containerView.bounds
videoLayer.frame = containerView.bounds
} else if let superview = containerView.superview {
containerView.frame = superview.bounds
metalLayer.frame = containerView.bounds
videoLayer.frame = containerView.bounds
} else if let window {
containerView.frame = window.bounds
metalLayer.frame = containerView.bounds
videoLayer.frame = containerView.bounds
}
mainBlankView?.frame = window?.bounds ?? .zero
let screen = containerView.window?.screen ?? window?.screen ?? UIScreen.main
let scale = screen.nativeScale > 0 ? screen.nativeScale : screen.scale
metalLayer.contentsScale = scale
metalLayer.drawableSize = CGSize(
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
)
videoLayer.contentsScale = scale
}
func externalDisplayDidChange() {
@@ -191,20 +154,19 @@ class MpvPlayerCore: MpvPlayerCoreBase {
mainBlankView = blankView
}
/// Nudge mpv to present the current paused frame after switching back from PiP.
/// Nudge mpv to present the current paused frame after leaving PiP.
func forceDraw() {
command(["seek", "0", "relative+exact"])
}
override func updateEDRMode(sigPeak: Double) {
guard let metalLayer else { return }
guard let videoLayer else { return }
var edrHeadroom: CGFloat = 1.0
#if os(iOS)
if #available(iOS 16.0, *) {
if #available(iOS 17.0, *) {
edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0
metalLayer.wantsExtendedDynamicRangeContent =
hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
videoLayer.wantsExtendedDynamicRangeContent = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
}
#endif
@@ -221,8 +183,8 @@ class MpvPlayerCore: MpvPlayerCoreBase {
#endif
disposeSharedState(destroySynchronously: false)
metalLayer?.removeFromSuperlayer()
metalLayer = nil
videoLayer?.removeFromSuperlayer()
videoLayer = nil
containerView?.removeFromSuperview()
containerView = nil
mainBlankView?.removeFromSuperview()
+18 -18
View File
@@ -96,9 +96,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
// MARK: - PiP
private func ensurePipController() -> MpvPipController {
private func ensurePipController() -> MpvPipController? {
if let existing = pipController { return existing }
let controller = MpvPipController()
guard let layer = playerCore?.sampleBufferDisplayLayer else { return nil }
let controller = MpvPipController(sampleBufferDisplayLayer: layer)
controller.delegate = self
pipController = controller
return controller
@@ -158,7 +159,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
self.autoPipEnabled = ready
if ready {
let pip = self.ensurePipController()
guard let pip = self.ensurePipController() else {
result(nil)
return
}
pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = self.playerCore {
@@ -175,16 +179,14 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
}
}
/// Switch to PiP VO and prepare the sample buffer layer for PiP display.
/// Prepare the shared AVFoundation display layer for PiP display.
/// Returns the MpvPipController on success, nil on failure.
@discardableResult
private func switchToPipAndPrepare() -> MpvPipController? {
private func preparePip() -> MpvPipController? {
guard let playerCore = playerCore else { return nil }
let pip = ensurePipController()
guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil }
guard let pip = ensurePipController() else { return nil }
pendingInlineRestoreAfterPip = false
playerCore.isPipStarting = true
pip.pushBlankFrame()
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
pip.invalidatePlaybackState()
return pip
@@ -205,10 +207,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
])
return
}
guard let pip = switchToPipAndPrepare() else {
guard let pip = preparePip() else {
result?([
"success": false, "errorCode": "vo_switch_failed",
"errorMessage": "Failed to switch VO",
"success": false, "errorCode": "pip_prepare_failed",
"errorMessage": "Failed to prepare PiP",
])
return
}
@@ -232,15 +234,13 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
playerCore?.isPipActive = false
isManualPipRequest = false
stopPipTimebaseSync()
pipController?.flushLayer()
let restoredInlineVO = playerCore?.switchToGpuNextVO() ?? false
if pause {
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] _ in
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
}
pendingInlineRestoreAfterPip = restoredInlineVO
pendingInlineRestoreAfterPip = true
if pendingInlineRestoreAfterPip {
if isSceneActive {
restoreInlinePlayerAfterPip()
@@ -393,11 +393,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
extension MpvPlayerPlugin: MpvPipDelegate {
func pipWillStart() {
// If PiP was system-initiated (not via our enterPip), switch VO now
// If PiP was system-initiated (not via our enterPip), prepare the shared layer now.
guard let playerCore = playerCore, !playerCore.isPipStarting else { return }
print("[MpvPlayerPlugin] System-initiated PiP detected, switching VO")
if switchToPipAndPrepare() == nil {
print("[MpvPlayerPlugin] VO switch failed for system-initiated PiP")
print("[MpvPlayerPlugin] System-initiated PiP detected, preparing shared layer")
if preparePip() == nil {
print("[MpvPlayerPlugin] PiP preparation failed for system-initiated PiP")
pipController?.stopPip()
}
}
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../i18n/strings.g.dart';
import '../../models/hotkey_model.dart';
import '../../services/keyboard_shortcuts_service.dart';
import '../../services/shader_service.dart';
import '../../utils/snackbar_helper.dart';
import '../../focus/focusable_button.dart';
import '../../widgets/focused_scroll_scaffold.dart';
@@ -19,7 +20,9 @@ class KeyboardShortcutsScreen extends StatelessWidget {
listenable: keyboardService,
builder: (context, _) {
final hotkeys = keyboardService.hotkeys;
final actions = hotkeys.keys.toList();
final actions = hotkeys.keys
.where((action) => action != 'shader_toggle' || ShaderService.isPlatformSupported)
.toList();
return FocusedScrollScaffold(
title: Text(t.settings.keyboardShortcuts),
slivers: [
+3 -1
View File
@@ -212,7 +212,9 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onLiveSeek: _captureBuffer != null ? _seekLivePosition : null,
onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null,
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
onToggleAmbientLighting: _toggleAmbientLighting,
onToggleAmbientLighting: _ambientLightingService?.isSupported == true
? _toggleAmbientLighting
: null,
toastController: _toastController,
),
);
@@ -37,8 +37,9 @@ extension _VideoPlayerPipShaderMethods on VideoPlayerScreenState {
if (_videoPIPManager == null || _videoFilterManager == null) return;
// Only handle exit - entry is handled by onBeforeEnterPip callback
if (!isInPip) {
if (isInPip) {
_videoFilterManager!.enterPipMode();
} else {
final restoreAmbient = _videoFilterManager!.hadAmbientLightingBeforePip;
_videoFilterManager!.exitPipMode();
// Restore ambient lighting if it was active before PiP
+1 -1
View File
@@ -27,7 +27,7 @@ class AmbientLightingService {
AmbientLightingService(this._player);
bool get isEnabled => _enabled;
bool get isSupported => _player.playerType == 'mpv';
bool get isSupported => _player.playerType == 'mpv' && !Platform.isIOS && !Platform.isMacOS;
/// Enable ambient lighting effect.
///
+5 -1
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import '../models/shader_preset.dart';
import '../mpv/player/player.dart';
import '../utils/app_logger.dart';
@@ -20,8 +22,10 @@ class ShaderService {
ShaderPreset get currentPreset => _currentPreset;
static bool get isPlatformSupported => !Platform.isIOS && !Platform.isMacOS;
/// Check if the player is MPV (shaders are MPV-only)
bool get isSupported => _player.playerType == 'mpv';
bool get isSupported => _player.playerType == 'mpv' && isPlatformSupported;
/// Apply a shader preset to the video player.
///
+3 -3
View File
@@ -68,9 +68,9 @@ class VideoPIPManager {
return (true, null);
}
// Reset video filter to contain mode BEFORE entering PiP (Android only —
// iOS switches VO entirely so the filter is irrelevant)
if (Platform.isAndroid) {
// Reset video filter to contain mode before entering PiP. Android, iOS,
// and macOS all reuse the inline video surface/layer for PiP.
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) {
onBeforeEnterPip?.call();
// Wait a frame for the filter change to take effect
await Future.delayed(const Duration(milliseconds: 50));
+1 -1
View File
@@ -875,7 +875,7 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = 29803ccbfa631997d496f34c2c1ed191cc8fd2c3;
revision = 8aa5e1abb68fffe1d29511da9e02feca575c9311;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3"
"revision" : "8aa5e1abb68fffe1d29511da9e02feca575c9311"
}
}
],
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3"
"revision" : "8aa5e1abb68fffe1d29511da9e02feca575c9311"
}
}
],
+11 -16
View File
@@ -13,8 +13,8 @@ protocol MpvPipDelegate: AnyObject {
}
/// 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.
/// This approach wraps the existing video layer in PiP no VO switching needed.
/// mpv continues rendering to its AVFoundation display layer throughout PiP.
class MpvPipController: NSObject, PIPViewControllerDelegate {
// MARK: - Properties
@@ -39,23 +39,18 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
static var isSupported: Bool { true }
/// Enter PiP by wrapping the given Metal layer in a view and presenting it.
/// Enter PiP by wrapping the given video 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) {
func startPip(videoLayer: CALayer, window: NSWindow, aspectRatio: NSSize) {
guard !isActive else { return }
sourceWindow = window
// Create a layer-hosting wrapper view for the Metal layer.
// Create a layer-hosting wrapper view for the video 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
videoView.layer = videoLayer
// Create a view controller for PIPViewController
let vc = NSViewController()
@@ -97,16 +92,16 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
autoPipEnabled = enabled
}
/// Clean up after PiP closes detaches the Metal layer from the wrapper view
/// Clean up after PiP closes detaches the video layer from the wrapper view
/// so MpvPlayerCore can re-add it to the main window.
/// Returns the Metal layer that was hosted in PiP.
/// Returns the layer that was hosted in PiP.
@discardableResult
func detachLayer() -> CAMetalLayer? {
let metalLayer = pipVideoView?.layer as? CAMetalLayer
func detachLayer() -> CALayer? {
let videoLayer = pipVideoView?.layer
pipVideoView?.layer = CALayer() // detach before removing
pipVideoView = nil
pipVideoVC = nil
return metalLayer
return videoLayer
}
// MARK: - PIPViewControllerDelegate
+52 -46
View File
@@ -1,7 +1,8 @@
import AVFoundation
import Cocoa
import Libmpv
/// Core MPV player using Metal rendering.
/// Core MPV player using AVFoundation sample-buffer rendering.
class MpvPlayerCore: MpvPlayerCoreBase {
private weak var window: NSWindow?
@@ -22,27 +23,28 @@ class MpvPlayerCore: MpvPlayerCoreBase {
self.window = window
let layer = MpvMetalLayer()
let layer = MpvVideoLayer()
layer.frame = contentView.bounds
if let screen = window.screen ?? NSScreen.main {
layer.contentsScale = screen.backingScaleFactor
}
layer.framebufferOnly = true
layer.isOpaque = true
layer.backgroundColor = NSColor.black.cgColor
layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
layer.videoGravity = .resizeAspect
metalLayer = layer
videoLayer = layer
updateEDRMode(sigPeak: lastSigPeak)
contentView.wantsLayer = true
contentView.layer?.addSublayer(layer)
print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)")
print("[MpvPlayerCore] Video layer added, frame: \(layer.frame)")
guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer()
metalLayer = nil
videoLayer = nil
return false
}
@@ -73,29 +75,23 @@ class MpvPlayerCore: MpvPlayerCoreBase {
override func configurePlatformMpvOptions() {
guard let mpv else { return }
checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "no"))
checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio"))
// Default fifo (vsync) mode mailbox was causing continuous GPU rendering even when paused
}
var videoLayer: CAMetalLayer? { metalLayer }
func reattachVideoLayer() {
guard let videoLayer, let contentView = window?.contentView else { return }
func reattachMetalLayer() {
guard let metalLayer, let contentView = window?.contentView else { return }
if metalLayer.superlayer == nil {
if videoLayer.superlayer == nil {
contentView.wantsLayer = true
contentView.layer?.insertSublayer(metalLayer, at: 0)
metalLayer.frame = contentView.bounds
contentView.layer?.insertSublayer(videoLayer, at: 0)
videoLayer.frame = contentView.bounds
if let screen = window?.screen ?? NSScreen.main {
metalLayer.contentsScale = screen.backingScaleFactor
metalLayer.drawableSize = CGSize(
width: contentView.bounds.width * screen.backingScaleFactor,
height: contentView.bounds.height * screen.backingScaleFactor
)
videoLayer.contentsScale = screen.backingScaleFactor
}
}
print("[MpvPlayerCore] Metal layer reattached to window")
print("[MpvPlayerCore] Video layer reattached to window")
}
func forceDraw() {
@@ -106,22 +102,22 @@ class MpvPlayerCore: MpvPlayerCoreBase {
private var pausedState = true
func setVisible(_ visible: Bool) {
guard let metalLayer, !isPipActive else { return }
guard let videoLayer, !isPipActive else { return }
isVisible = visible
isBackgrounded = !visible
if visible {
metalLayer.removeFromSuperlayer()
videoLayer.removeFromSuperlayer()
if let superlayer = window?.contentView?.layer {
superlayer.insertSublayer(metalLayer, at: 0)
superlayer.insertSublayer(videoLayer, at: 0)
}
beginPlaybackActivity()
} else {
endPlaybackActivity()
}
metalLayer.isHidden = !visible
videoLayer.isHidden = !visible
print("[MpvPlayerCore] setVisible(\(visible))")
}
@@ -135,38 +131,48 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
func updateFrame(_ frame: CGRect? = nil) {
guard let metalLayer, !isPipActive else { return }
guard let videoLayer, !isPipActive else { return }
if let frame {
metalLayer.frame = frame
videoLayer.frame = frame
} else if let contentView = window?.contentView {
metalLayer.frame = contentView.bounds
videoLayer.frame = contentView.bounds
}
if let screen = window?.screen ?? NSScreen.main {
let scale = screen.backingScaleFactor
metalLayer.drawableSize = CGSize(
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
)
videoLayer.contentsScale = screen.backingScaleFactor
}
updateEDRMode(sigPeak: lastSigPeak)
print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)")
print("[MpvPlayerCore] updateFrame: \(videoLayer.frame)")
}
override func updateEDRMode(sigPeak: Double) {
guard let metalLayer else { return }
guard let videoLayer else { return }
var edrHeadroom: CGFloat = 1.0
var currentHeadroom: CGFloat = 1.0
var potentialHeadroom: CGFloat = 1.0
if let screen = window?.screen ?? NSScreen.main {
edrHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue
currentHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue
potentialHeadroom = screen.maximumPotentialExtendedDynamicRangeColorComponentValue
}
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
metalLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
let signalHeadroom = CGFloat(max(sigPeak, 1.0))
let contentHeadroom = min(signalHeadroom, potentialHeadroom)
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && potentialHeadroom > 1.0
if #available(macOS 26.0, *) {
videoLayer.preferredDynamicRange = shouldEnableEDR ? .high : .standard
videoLayer.contentsHeadroom = shouldEnableEDR ? contentHeadroom : 0
}
if #available(macOS 15.0, *) {
videoLayer.toneMapMode = shouldEnableEDR ? .ifSupported : .automatic
}
if #available(macOS 14.0, *) {
videoLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
}
print(
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))"
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), currentHeadroom: \(currentHeadroom), potentialHeadroom: \(potentialHeadroom), contentHeadroom: \(shouldEnableEDR ? contentHeadroom : 0))"
)
}
@@ -178,8 +184,8 @@ class MpvPlayerCore: MpvPlayerCoreBase {
NotificationCenter.default.removeObserver(self)
disposeSharedState(destroySynchronously: false)
metalLayer?.removeFromSuperlayer()
metalLayer = nil
videoLayer?.removeFromSuperlayer()
videoLayer = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
@@ -199,19 +205,19 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
@objc private func windowOcclusionDidChange(_ notification: Notification) {
guard let metalLayer, mpv != nil, !isPipActive else { return }
guard let videoLayer, mpv != nil, !isPipActive else { return }
let windowVisible = window?.occlusionState.contains(.visible) ?? true
if !windowVisible && !layerHiddenForOcclusion {
print("[MpvPlayerCore] Window occluded - hiding Metal layer")
metalLayer.isHidden = true
print("[MpvPlayerCore] Window occluded - hiding video layer")
videoLayer.isHidden = true
layerHiddenForOcclusion = true
isBackgrounded = true
endPlaybackActivity()
} else if windowVisible && layerHiddenForOcclusion {
print("[MpvPlayerCore] Window visible - showing Metal layer")
print("[MpvPlayerCore] Window visible - showing video layer")
layerHiddenForOcclusion = false
metalLayer.isHidden = false
videoLayer.isHidden = false
isBackgrounded = false
if !pausedState {
beginPlaybackActivity()
+7 -7
View File
@@ -158,8 +158,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
}
}
/// Enter PiP by moving the Metal rendering layer to a PiP window.
/// No VO switching mpv keeps rendering to the same Metal layer.
/// Enter PiP by moving the AVFoundation video layer to a PiP window.
/// No VO switching mpv keeps rendering to the same layer.
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
guard let playerCore = playerCore else {
result?([
@@ -167,7 +167,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
])
return
}
guard let metalLayer = playerCore.videoLayer else {
guard let videoLayer = playerCore.videoLayer else {
result?(["success": false, "errorCode": "failed", "errorMessage": "No video layer"])
return
}
@@ -191,7 +191,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
enteredPipViaAuto = !manual
playerCore.isPipActive = true
pip.startPip(metalLayer: metalLayer, window: window, aspectRatio: aspectRatio)
pip.startPip(videoLayer: videoLayer, window: window, aspectRatio: aspectRatio)
pipChannel?.invokeMethod("onPipChanged", arguments: true)
result?(["success": true])
}
@@ -353,11 +353,11 @@ extension MpvPlayerPlugin: MpvPipDelegate {
playerCore?.isPipActive = false
enteredPipViaAuto = false
// Detach the Metal layer from the PiP wrapper view
// Detach the video layer from the PiP wrapper view
pipController?.detachLayer()
// Re-attach the Metal layer to the main window
playerCore?.reattachMetalLayer()
// Re-attach the video layer to the main window
playerCore?.reattachVideoLayer()
// Force a redraw if paused (prevents black frame after PiP exit)
if playerCore?.isPaused == true {
+1 -1
View File
@@ -1,7 +1,7 @@
name: plezy
description: "A beautiful Plex client for Flutter"
publish_to: "none"
version: 2.0.0+82
version: 2.0.0+84
environment:
sdk: ">=3.10.7 <4.0.0"
+60 -57
View File
@@ -1,3 +1,4 @@
import AVFoundation
import Foundation
import Libmpv
import QuartzCore
@@ -13,49 +14,7 @@ protocol MpvPlayerDelegate: AnyObject {
func onEvent(name: String, data: [String: Any]?)
}
// Workaround for MoltenVK problems that cause flicker.
// https://github.com/mpv-player/mpv/pull/13651
class MpvMetalLayer: CAMetalLayer {
override var drawableSize: CGSize {
get { super.drawableSize }
set {
if newValue == .zero || (Int(newValue.width) > 1 && Int(newValue.height) > 1) {
super.drawableSize = newValue
}
}
}
#if os(iOS)
// wantsExtendedDynamicRangeContent is unavailable on tvOS as of SDK 26.4,
// so this override only applies to iOS / macOS.
@available(iOS 16.0, *)
override var wantsExtendedDynamicRangeContent: Bool {
get { super.wantsExtendedDynamicRangeContent }
set {
if Thread.isMainThread {
super.wantsExtendedDynamicRangeContent = newValue
} else {
DispatchQueue.main.sync {
super.wantsExtendedDynamicRangeContent = newValue
}
}
}
}
#elseif os(macOS)
override var wantsExtendedDynamicRangeContent: Bool {
get { super.wantsExtendedDynamicRangeContent }
set {
if Thread.isMainThread {
super.wantsExtendedDynamicRangeContent = newValue
} else {
DispatchQueue.main.async {
super.wantsExtendedDynamicRangeContent = newValue
}
}
}
}
#endif
}
class MpvVideoLayer: AVSampleBufferDisplayLayer {}
/// Safely convert a C string to Swift String with UTF-8 validation.
/// Falls back to Latin-1 decoding if the bytes are not valid UTF-8.
@@ -77,7 +36,7 @@ func safeString(_ cstr: UnsafePointer<CChar>) -> String {
class MpvPlayerCoreBase: NSObject {
weak var delegate: MpvPlayerDelegate?
var metalLayer: MpvMetalLayer?
var videoLayer: MpvVideoLayer?
var mpv: OpaquePointer?
var isInitialized = false
var isDisposing = false
@@ -118,6 +77,8 @@ class MpvPlayerCoreBase: NSObject {
private var cachedTimePos = 0.0
private var cachedWidth = 0.0
private var cachedHeight = 0.0
private var currentPanscan = 0.0
private var aspectOverrideActive = false
override init() {
super.init()
@@ -129,7 +90,7 @@ class MpvPlayerCoreBase: NSObject {
func updateEDRMode(sigPeak: Double) {}
func setupMpv() -> Bool {
guard let metalLayer else { return false }
guard let videoLayer else { return false }
mpv = mpv_create()
guard let mpv else {
@@ -143,7 +104,7 @@ class MpvPlayerCoreBase: NSObject {
checkError(mpv_request_log_messages(mpv, "warn"))
#endif
var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(metalLayer).toOpaque()))
var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(videoLayer).toOpaque()))
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer))
applySharedMpvOptions()
configurePlatformMpvOptions()
@@ -186,6 +147,21 @@ class MpvPlayerCoreBase: NSObject {
value: String,
completion: @escaping (Result<Void, Error>) -> Void
) {
#if targetEnvironment(simulator)
if name == "hwdec" {
completion(.success(()))
return
}
#endif
if isAvFoundationManagedProperty(name) {
print("[MpvPlayerCore] Ignoring managed AVFoundation property: \(name)=\(value)")
completion(.success(()))
return
}
updateVideoGravityIfNeeded(name: name, value: value)
if name == "pause" {
setCachedPaused(value == "yes" || value == "true" || value == "1")
}
@@ -365,22 +341,49 @@ class MpvPlayerCoreBase: NSObject {
}
}
func applyGpuNextOptions() {
guard mpv != nil else { return }
setProperty("gpu-api", value: "vulkan")
setProperty("gpu-context", value: "moltenvk")
setProperty("vo", value: "gpu-next")
}
private func applySharedMpvOptions() {
guard let mpv else { return }
checkError(mpv_set_option_string(mpv, "vo", "gpu-next"))
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
checkError(mpv_set_option_string(mpv, "vo", "avfoundation"))
#if targetEnvironment(simulator)
checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "no"))
checkError(mpv_set_option_string(mpv, "hwdec", "no"))
#else
checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "yes"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
#endif
checkError(mpv_set_option_string(mpv, "hwdec-codecs", "all"))
checkError(mpv_set_option_string(mpv, "hwdec-software-fallback", "yes"))
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes"))
}
private func isAvFoundationManagedProperty(_ name: String) -> Bool {
name == "vo" || name == "wid" || name == "gpu-api" || name == "gpu-context"
}
private func updateVideoGravityIfNeeded(name: String, value: String) {
switch name {
case "panscan":
currentPanscan = Double(value) ?? 0
case "video-aspect-override":
aspectOverrideActive = value != "no" && value != "-1" && value != "0"
default:
return
}
let gravity: AVLayerVideoGravity
if aspectOverrideActive {
gravity = .resize
} else if currentPanscan > 0 {
gravity = .resizeAspectFill
} else {
gravity = .resizeAspect
}
DispatchQueue.main.async { [weak self] in
self?.videoLayer?.videoGravity = gravity
}
}
private func cancelPendingRequests() {
pendingRequestsLock.lock()
let pending = pendingRequests
+2 -2
View File
@@ -711,8 +711,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
branch = main;
kind = branch;
kind = revision;
revision = 8aa5e1abb68fffe1d29511da9e02feca575c9311;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -6,8 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"branch" : "main",
"revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3"
"revision" : "8aa5e1abb68fffe1d29511da9e02feca575c9311"
}
}
],
@@ -6,8 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"branch" : "main",
"revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3"
"revision" : "8aa5e1abb68fffe1d29511da9e02feca575c9311"
}
}
],
+4 -2
View File
@@ -42,16 +42,18 @@ end
# Swift Package: MPVKit.
pkg_url = 'https://github.com/edde746/MPVKit'
pkg_revision = '8aa5e1abb68fffe1d29511da9e02feca575c9311'
existing_pkg = project.root_object.package_references.find do |p|
p.repositoryURL == pkg_url rescue false
end
if existing_pkg
puts "[skip] MPVKit SPM package already present"
existing_pkg.requirement = { 'kind' => 'revision', 'revision' => pkg_revision }
puts "[set ] MPVKit SPM package revision"
else
pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
pkg.repositoryURL = pkg_url
pkg.requirement = { 'kind' => 'branch', 'branch' => 'main' }
pkg.requirement = { 'kind' => 'revision', 'revision' => pkg_revision }
project.root_object.package_references << pkg
product = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)