feat(apple): use AVFoundation mpv output
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user