chore: add native formatting checks
This commit is contained in:
+2
-2
@@ -125,7 +125,7 @@ SPEC CHECKSUMS:
|
||||
file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
|
||||
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
|
||||
os_media_controls: 94cc278f5802b82b2d6373003aeb511f96718b27
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be
|
||||
sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f
|
||||
@@ -133,7 +133,7 @@ SPEC CHECKSUMS:
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921
|
||||
sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab
|
||||
universal_gamepad: e10172778a8a399cce234494968f38724974919e
|
||||
universal_gamepad: 838bbb70d37d8c7c719038aa397214f2c4c4f866
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import AVKit
|
||||
import UIKit
|
||||
|
||||
#if os(tvOS)
|
||||
// tvOS stub: AVPictureInPictureController has different constraints on tvOS
|
||||
// and is not supported by the Plezy flow. Provide a no-op shell so callers
|
||||
// in MpvPlayerPlugin compile unchanged; isSupported reports false so PiP is
|
||||
// never attempted at runtime.
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
// tvOS stub: AVPictureInPictureController has different constraints on tvOS
|
||||
// and is not supported by the Plezy flow. Provide a no-op shell so callers
|
||||
// in MpvPlayerPlugin compile unchanged; isSupported reports false so PiP is
|
||||
// never attempted at runtime.
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
func pipWillStart()
|
||||
func pipDidStart()
|
||||
func pipDidStop(restored: Bool)
|
||||
@@ -15,35 +15,35 @@ protocol MpvPipDelegate: AnyObject {
|
||||
func pipSkip(byInterval seconds: Double)
|
||||
var isPipPlaying: Bool { get }
|
||||
var pipDuration: Double { get }
|
||||
}
|
||||
}
|
||||
|
||||
class MpvPipController: NSObject {
|
||||
class MpvPipController: NSObject {
|
||||
static var isSupported: Bool { false }
|
||||
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)!
|
||||
// Return a dummy non-null pointer — layerPointer is handed to mpv for
|
||||
// rendering into PiP, which never activates on tvOS.
|
||||
UnsafeMutableRawPointer(bitPattern: 0x1)!
|
||||
}
|
||||
func setup(with layer: CALayer, containerView: UIView) {}
|
||||
func setAutoStart(_ enabled: Bool) {}
|
||||
func warmLayer(currentTime: Double, isPlaying: Bool) {}
|
||||
func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {}
|
||||
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
|
||||
completion(false)
|
||||
completion(false)
|
||||
}
|
||||
func stopPip() {}
|
||||
func invalidatePlaybackState() {}
|
||||
func flushLayer() {}
|
||||
func syncTimebase(currentTime: Double, isPlaying: Bool) {}
|
||||
func teardown() {}
|
||||
}
|
||||
}
|
||||
#else
|
||||
|
||||
/// Delegate to notify the plugin of PiP lifecycle events
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
/// Delegate to notify the plugin of PiP lifecycle events
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
/// Called when PiP is about to start (system or app-initiated)
|
||||
func pipWillStart()
|
||||
func pipDidStart()
|
||||
@@ -58,11 +58,11 @@ protocol MpvPipDelegate: AnyObject {
|
||||
var isPipPlaying: Bool { get }
|
||||
/// Get total duration in seconds
|
||||
var pipDuration: Double { get }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
|
||||
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
|
||||
class MpvPipController: NSObject {
|
||||
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
|
||||
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
|
||||
class MpvPipController: NSObject {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@@ -73,340 +73,346 @@ class MpvPipController: NSObject {
|
||||
|
||||
/// Pointer to the sample buffer layer for passing to mpv as `wid`
|
||||
var layerPointer: UnsafeMutableRawPointer {
|
||||
Unmanaged.passUnretained(sampleBufferLayer).toOpaque()
|
||||
Unmanaged.passUnretained(sampleBufferLayer).toOpaque()
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
setup()
|
||||
super.init()
|
||||
setup()
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("[MpvPipController] Failed to configure audio session: \(error)")
|
||||
}
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
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 windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first(where: { $0.isKeyWindow }) {
|
||||
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
|
||||
}
|
||||
// The sample buffer layer must be in a visible view hierarchy for
|
||||
// isPictureInPicturePossible to become true.
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first(where: { $0.isKeyWindow })
|
||||
{
|
||||
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()
|
||||
createPipController()
|
||||
}
|
||||
|
||||
/// Helper that conforms to the iOS 15+ delegate protocols
|
||||
private var delegateHelper: AnyObject?
|
||||
|
||||
private func createPipController() {
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
let helper = PipDelegateHelper(controller: self)
|
||||
let contentSource = AVPictureInPictureController.ContentSource(
|
||||
sampleBufferDisplayLayer: sampleBufferLayer,
|
||||
playbackDelegate: helper
|
||||
)
|
||||
self.delegateHelper = helper
|
||||
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||
pipController?.delegate = helper
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
}
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
let helper = PipDelegateHelper(controller: self)
|
||||
let contentSource = AVPictureInPictureController.ContentSource(
|
||||
sampleBufferDisplayLayer: sampleBufferLayer,
|
||||
playbackDelegate: helper
|
||||
)
|
||||
self.delegateHelper = helper
|
||||
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||
pipController?.delegate = helper
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable/disable system auto-PiP (starts PiP automatically on background transition)
|
||||
func setAutoStart(_ enabled: Bool) {
|
||||
guard #available(iOS 14.2, *) else { return }
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
|
||||
guard #available(iOS 14.2, *) else { return }
|
||||
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).
|
||||
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 }
|
||||
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, [])
|
||||
// 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)
|
||||
}
|
||||
// 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
|
||||
)
|
||||
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 }
|
||||
// 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 }
|
||||
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
|
||||
}
|
||||
// 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)
|
||||
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 }
|
||||
let cmTime = CMTime(seconds: currentTime, preferredTimescale: 1000)
|
||||
CMTimebaseSetTime(timebase, time: cmTime)
|
||||
CMTimebaseSetRate(timebase, rate: isPlaying ? 1.0 : 0.0)
|
||||
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).
|
||||
func warmLayer(currentTime: Double, isPlaying: Bool) {
|
||||
if sampleBufferLayer.controlTimebase == nil {
|
||||
var timebase: CMTimebase?
|
||||
CMTimebaseCreateWithSourceClock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
sourceClock: CMClockGetHostTimeClock(),
|
||||
timebaseOut: &timebase
|
||||
)
|
||||
if let tb = timebase {
|
||||
sampleBufferLayer.controlTimebase = tb
|
||||
}
|
||||
if sampleBufferLayer.controlTimebase == nil {
|
||||
var timebase: CMTimebase?
|
||||
CMTimebaseCreateWithSourceClock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
sourceClock: CMClockGetHostTimeClock(),
|
||||
timebaseOut: &timebase
|
||||
)
|
||||
if let tb = timebase {
|
||||
sampleBufferLayer.controlTimebase = tb
|
||||
}
|
||||
syncTimebase(currentTime: currentTime, isPlaying: isPlaying)
|
||||
pushBlankFrame()
|
||||
}
|
||||
syncTimebase(currentTime: currentTime, isPlaying: isPlaying)
|
||||
pushBlankFrame()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
static var isSupported: Bool {
|
||||
guard #available(iOS 15.0, *) else { return false }
|
||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||
guard #available(iOS 15.0, *) else { return false }
|
||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
/// Start PiP. When `waitForFrame` is false (auto-PiP), skips the frame
|
||||
/// readiness check since the scene is about to deactivate.
|
||||
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
|
||||
guard let pipController = pipController else {
|
||||
completion(false)
|
||||
return
|
||||
guard let pipController = pipController else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
var attempts = 0
|
||||
func tryStart() {
|
||||
let possible = pipController.isPictureInPicturePossible
|
||||
let hasTimebase = 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
|
||||
} else {
|
||||
hasFrame = true
|
||||
}
|
||||
|
||||
var attempts = 0
|
||||
func tryStart() {
|
||||
let possible = pipController.isPictureInPicturePossible
|
||||
let hasTimebase = 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
|
||||
} else {
|
||||
hasFrame = true
|
||||
}
|
||||
|
||||
if possible && hasTimebase && hasFrame {
|
||||
print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP")
|
||||
pipController.startPictureInPicture()
|
||||
completion(true)
|
||||
} else if attempts < 40 {
|
||||
attempts += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
|
||||
} else {
|
||||
print("[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))")
|
||||
completion(false)
|
||||
}
|
||||
if possible && hasTimebase && hasFrame {
|
||||
print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP")
|
||||
pipController.startPictureInPicture()
|
||||
completion(true)
|
||||
} else if attempts < 40 {
|
||||
attempts += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
|
||||
} else {
|
||||
print(
|
||||
"[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))"
|
||||
)
|
||||
completion(false)
|
||||
}
|
||||
tryStart()
|
||||
}
|
||||
tryStart()
|
||||
}
|
||||
|
||||
func stopPip() {
|
||||
pipController?.stopPictureInPicture()
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
/// Invalidate the playback state so PiP updates its UI (play/pause button)
|
||||
func invalidatePlaybackState() {
|
||||
pipController?.invalidatePlaybackState()
|
||||
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.
|
||||
func teardown() {
|
||||
pipController?.stopPictureInPicture()
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
}
|
||||
pipController = nil
|
||||
delegateHelper = nil
|
||||
sampleBufferLayer.flushAndRemoveImage()
|
||||
sampleBufferLayer.controlTimebase = nil
|
||||
sampleBufferLayer.removeFromSuperlayer()
|
||||
containerView?.removeFromSuperview()
|
||||
containerView = nil
|
||||
pipController?.stopPictureInPicture()
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
}
|
||||
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
|
||||
func flushLayer() {
|
||||
sampleBufferLayer.flushAndRemoveImage()
|
||||
sampleBufferLayer.flushAndRemoveImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PiP Delegate Helper (iOS 15+)
|
||||
// MARK: - PiP Delegate Helper (iOS 15+)
|
||||
|
||||
/// Separate class conforming to AVPictureInPictureControllerDelegate and
|
||||
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
|
||||
/// availability for the ContentSource-based delegate methods.
|
||||
@available(iOS 15.0, *)
|
||||
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
|
||||
/// Separate class conforming to AVPictureInPictureControllerDelegate and
|
||||
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
|
||||
/// availability for the ContentSource-based delegate methods.
|
||||
@available(iOS 15.0, *)
|
||||
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
|
||||
AVPictureInPictureSampleBufferPlaybackDelegate
|
||||
{
|
||||
{
|
||||
weak var controller: MpvPipController?
|
||||
private var isRestoring = false
|
||||
|
||||
init(controller: MpvPipController) {
|
||||
self.controller = controller
|
||||
super.init()
|
||||
self.controller = controller
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureControllerDelegate
|
||||
|
||||
func pictureInPictureControllerWillStartPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
print("[MpvPipController] PiP will start")
|
||||
controller?.delegate?.pipWillStart()
|
||||
print("[MpvPipController] PiP will start")
|
||||
controller?.delegate?.pipWillStart()
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
print("[MpvPipController] PiP did start")
|
||||
controller?.delegate?.pipDidStart()
|
||||
print("[MpvPipController] PiP did start")
|
||||
controller?.delegate?.pipDidStart()
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
let restored = isRestoring
|
||||
isRestoring = false
|
||||
print("[MpvPipController] PiP did stop (restored: \(restored))")
|
||||
controller?.delegate?.pipDidStop(restored: restored)
|
||||
let restored = isRestoring
|
||||
isRestoring = false
|
||||
print("[MpvPipController] PiP did stop (restored: \(restored))")
|
||||
controller?.delegate?.pipDidStop(restored: restored)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
failedToStartPictureInPictureWithError error: Error
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
failedToStartPictureInPictureWithError error: Error
|
||||
) {
|
||||
print("[MpvPipController] PiP failed to start: \(error)")
|
||||
controller?.delegate?.pipDidFailToStart(error: error)
|
||||
print("[MpvPipController] PiP failed to start: \(error)")
|
||||
controller?.delegate?.pipDidFailToStart(error: error)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler:
|
||||
@escaping (Bool) -> Void
|
||||
) {
|
||||
print("[MpvPipController] PiP restore user interface")
|
||||
isRestoring = true
|
||||
completionHandler(true)
|
||||
print("[MpvPipController] PiP restore user interface")
|
||||
isRestoring = true
|
||||
completionHandler(true)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
print("[MpvPipController] PiP will stop")
|
||||
print("[MpvPipController] PiP will stop")
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
setPlaying playing: Bool
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
setPlaying playing: Bool
|
||||
) {
|
||||
print("[MpvPipController] PiP setPlaying: \(playing)")
|
||||
controller?.delegate?.pipSetPlaying(playing)
|
||||
print("[MpvPipController] PiP setPlaying: \(playing)")
|
||||
controller?.delegate?.pipSetPlaying(playing)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerTimeRangeForPlayback(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) -> CMTimeRange {
|
||||
let duration = controller?.delegate?.pipDuration ?? 0
|
||||
if duration > 0 {
|
||||
return CMTimeRange(
|
||||
start: .zero,
|
||||
duration: CMTime(seconds: duration, preferredTimescale: 1000)
|
||||
)
|
||||
}
|
||||
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
|
||||
let duration = controller?.delegate?.pipDuration ?? 0
|
||||
if duration > 0 {
|
||||
return CMTimeRange(
|
||||
start: .zero,
|
||||
duration: CMTime(seconds: duration, preferredTimescale: 1000)
|
||||
)
|
||||
}
|
||||
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
|
||||
}
|
||||
|
||||
func pictureInPictureControllerIsPlaybackPaused(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) -> Bool {
|
||||
return !(controller?.delegate?.isPipPlaying ?? false)
|
||||
return !(controller?.delegate?.isPipPlaying ?? false)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
didTransitionToRenderSize newRenderSize: CMVideoDimensions
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
didTransitionToRenderSize newRenderSize: CMVideoDimensions
|
||||
) {}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
skipByInterval skipInterval: CMTime,
|
||||
completion completionHandler: @escaping () -> Void
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
skipByInterval skipInterval: CMTime,
|
||||
completion completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let seconds = CMTimeGetSeconds(skipInterval)
|
||||
print("[MpvPipController] PiP skip by \(seconds)s")
|
||||
controller?.delegate?.pipSkip(byInterval: seconds)
|
||||
completionHandler()
|
||||
let seconds = CMTimeGetSeconds(skipInterval)
|
||||
print("[MpvPipController] PiP skip by \(seconds)s")
|
||||
controller?.delegate?.pipSkip(byInterval: seconds)
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif // !os(tvOS)
|
||||
|
||||
@@ -4,195 +4,195 @@ import UIKit
|
||||
/// Core MPV player using Metal rendering for iOS.
|
||||
class MpvPlayerCore: MpvPlayerCoreBase {
|
||||
|
||||
private var containerView: UIView?
|
||||
private weak var window: UIWindow?
|
||||
private var containerView: UIView?
|
||||
private weak var window: UIWindow?
|
||||
|
||||
var isPipStarting = false
|
||||
var isPipStarting = false
|
||||
|
||||
func initialize(in window: UIWindow) -> Bool {
|
||||
guard !isInitialized else {
|
||||
print("[MpvPlayerCore] Already initialized")
|
||||
return true
|
||||
}
|
||||
|
||||
self.window = window
|
||||
|
||||
let container = UIView(frame: window.bounds)
|
||||
container.backgroundColor = .clear
|
||||
container.isUserInteractionEnabled = false
|
||||
|
||||
let layer = MpvMetalLayer()
|
||||
layer.frame = container.bounds
|
||||
layer.contentsScale = UIScreen.main.nativeScale
|
||||
layer.framebufferOnly = true
|
||||
layer.backgroundColor = UIColor.black.cgColor
|
||||
|
||||
container.layer.addSublayer(layer)
|
||||
containerView = container
|
||||
metalLayer = layer
|
||||
|
||||
window.insertSubview(container, at: 0)
|
||||
|
||||
guard setupMpv() else {
|
||||
print("[MpvPlayerCore] Failed to setup MPV")
|
||||
layer.removeFromSuperlayer()
|
||||
container.removeFromSuperview()
|
||||
metalLayer = nil
|
||||
containerView = nil
|
||||
return false
|
||||
}
|
||||
|
||||
setupNotifications()
|
||||
|
||||
isInitialized = true
|
||||
print("[MpvPlayerCore] Initialized successfully with MPV")
|
||||
return true
|
||||
func initialize(in window: UIWindow) -> Bool {
|
||||
guard !isInitialized else {
|
||||
print("[MpvPlayerCore] Already initialized")
|
||||
return true
|
||||
}
|
||||
|
||||
func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool {
|
||||
guard let mpv else { return false }
|
||||
self.window = window
|
||||
|
||||
print("[MpvPlayerCore] Switching to pip VO for PiP")
|
||||
let container = UIView(frame: window.bounds)
|
||||
container.backgroundColor = .clear
|
||||
container.isUserInteractionEnabled = false
|
||||
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
let layer = MpvMetalLayer()
|
||||
layer.frame = container.bounds
|
||||
layer.contentsScale = UIScreen.main.nativeScale
|
||||
layer.framebufferOnly = true
|
||||
layer.backgroundColor = UIColor.black.cgColor
|
||||
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
container.layer.addSublayer(layer)
|
||||
containerView = container
|
||||
metalLayer = layer
|
||||
|
||||
var pointer = Int64(Int(bitPattern: layerPtr))
|
||||
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &pointer)
|
||||
window.insertSubview(container, at: 0)
|
||||
|
||||
mpv_set_property_string(mpv, "vo", "pip")
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
|
||||
print("[MpvPlayerCore] Switched to pip VO successfully")
|
||||
return true
|
||||
guard setupMpv() else {
|
||||
print("[MpvPlayerCore] Failed to setup MPV")
|
||||
layer.removeFromSuperlayer()
|
||||
container.removeFromSuperview()
|
||||
metalLayer = nil
|
||||
containerView = nil
|
||||
return false
|
||||
}
|
||||
|
||||
func switchToGpuNextVO() -> Bool {
|
||||
guard let mpv, let metalLayer else { return false }
|
||||
setupNotifications()
|
||||
|
||||
print("[MpvPlayerCore] Switching back to gpu-next VO")
|
||||
isInitialized = true
|
||||
print("[MpvPlayerCore] Initialized successfully with MPV")
|
||||
return true
|
||||
}
|
||||
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool {
|
||||
guard let mpv else { return false }
|
||||
|
||||
var layer = metalLayer
|
||||
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer)
|
||||
print("[MpvPlayerCore] Switching to pip VO for PiP")
|
||||
|
||||
applyGpuNextOptions()
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
|
||||
if metalLayer.superlayer == nil, let containerView {
|
||||
containerView.layer.addSublayer(metalLayer)
|
||||
}
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
|
||||
print("[MpvPlayerCore] Switched back to gpu-next VO successfully")
|
||||
return true
|
||||
var pointer = Int64(Int(bitPattern: layerPtr))
|
||||
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &pointer)
|
||||
|
||||
mpv_set_property_string(mpv, "vo", "pip")
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
|
||||
print("[MpvPlayerCore] Switched to pip VO successfully")
|
||||
return true
|
||||
}
|
||||
|
||||
func switchToGpuNextVO() -> Bool {
|
||||
guard let mpv, let metalLayer else { return false }
|
||||
|
||||
print("[MpvPlayerCore] Switching back to gpu-next VO")
|
||||
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
|
||||
var layer = metalLayer
|
||||
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer)
|
||||
|
||||
applyGpuNextOptions()
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
|
||||
if metalLayer.superlayer == nil, let containerView {
|
||||
containerView.layer.addSublayer(metalLayer)
|
||||
}
|
||||
|
||||
func setVisible(_ visible: Bool) {
|
||||
guard let containerView else { return }
|
||||
print("[MpvPlayerCore] Switched back to gpu-next VO successfully")
|
||||
return true
|
||||
}
|
||||
|
||||
if visible {
|
||||
containerView.removeFromSuperview()
|
||||
window?.insertSubview(containerView, at: 0)
|
||||
}
|
||||
func setVisible(_ visible: Bool) {
|
||||
guard let containerView else { return }
|
||||
|
||||
containerView.isHidden = !visible
|
||||
if visible {
|
||||
containerView.removeFromSuperview()
|
||||
window?.insertSubview(containerView, at: 0)
|
||||
}
|
||||
|
||||
func updateFrame(_ frame: CGRect? = nil) {
|
||||
guard let metalLayer, let containerView else { return }
|
||||
containerView.isHidden = !visible
|
||||
}
|
||||
|
||||
if let frame {
|
||||
containerView.frame = frame
|
||||
metalLayer.frame = containerView.bounds
|
||||
} else if let window {
|
||||
containerView.frame = window.bounds
|
||||
metalLayer.frame = containerView.bounds
|
||||
}
|
||||
func updateFrame(_ frame: CGRect? = nil) {
|
||||
guard let metalLayer, let containerView else { return }
|
||||
|
||||
let scale = UIScreen.main.nativeScale
|
||||
metalLayer.drawableSize = CGSize(
|
||||
width: metalLayer.frame.width * scale,
|
||||
height: metalLayer.frame.height * scale
|
||||
)
|
||||
if let frame {
|
||||
containerView.frame = frame
|
||||
metalLayer.frame = containerView.bounds
|
||||
} else if let window {
|
||||
containerView.frame = window.bounds
|
||||
metalLayer.frame = containerView.bounds
|
||||
}
|
||||
|
||||
/// Nudge mpv to present the current paused frame after switching back from PiP.
|
||||
func forceDraw() {
|
||||
command(["seek", "0", "relative+exact"])
|
||||
let scale = UIScreen.main.nativeScale
|
||||
metalLayer.drawableSize = CGSize(
|
||||
width: metalLayer.frame.width * scale,
|
||||
height: metalLayer.frame.height * scale
|
||||
)
|
||||
}
|
||||
|
||||
/// Nudge mpv to present the current paused frame after switching back from PiP.
|
||||
func forceDraw() {
|
||||
command(["seek", "0", "relative+exact"])
|
||||
}
|
||||
|
||||
override func updateEDRMode(sigPeak: Double) {
|
||||
guard let metalLayer else { return }
|
||||
|
||||
var edrHeadroom: CGFloat = 1.0
|
||||
#if os(iOS)
|
||||
if #available(iOS 16.0, *) {
|
||||
edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0
|
||||
metalLayer.wantsExtendedDynamicRangeContent =
|
||||
hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
|
||||
}
|
||||
#endif
|
||||
|
||||
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
|
||||
print(
|
||||
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))"
|
||||
)
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
disposeSharedState(destroySynchronously: false)
|
||||
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
containerView?.removeFromSuperview()
|
||||
containerView = nil
|
||||
isInitialized = false
|
||||
print("[MpvPlayerCore] Disposed")
|
||||
}
|
||||
|
||||
deinit {
|
||||
dispose()
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterForeground),
|
||||
name: UIApplication.willEnterForegroundNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func enterBackground() {
|
||||
if isPipActive || isPipStarting {
|
||||
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
|
||||
return
|
||||
}
|
||||
|
||||
override func updateEDRMode(sigPeak: Double) {
|
||||
guard let metalLayer else { return }
|
||||
print("[MpvPlayerCore] Entering background - disabling video")
|
||||
if mpv != nil {
|
||||
mpv_set_option_string(mpv, "vid", "no")
|
||||
}
|
||||
}
|
||||
|
||||
var edrHeadroom: CGFloat = 1.0
|
||||
#if os(iOS)
|
||||
if #available(iOS 16.0, *) {
|
||||
edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0
|
||||
metalLayer.wantsExtendedDynamicRangeContent =
|
||||
hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
|
||||
}
|
||||
#endif
|
||||
|
||||
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
|
||||
print(
|
||||
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))"
|
||||
)
|
||||
@objc private func enterForeground() {
|
||||
if isPipActive {
|
||||
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
|
||||
return
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
disposeSharedState(destroySynchronously: false)
|
||||
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
containerView?.removeFromSuperview()
|
||||
containerView = nil
|
||||
isInitialized = false
|
||||
print("[MpvPlayerCore] Disposed")
|
||||
}
|
||||
|
||||
deinit {
|
||||
dispose()
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterForeground),
|
||||
name: UIApplication.willEnterForegroundNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func enterBackground() {
|
||||
if isPipActive || isPipStarting {
|
||||
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
|
||||
return
|
||||
}
|
||||
|
||||
print("[MpvPlayerCore] Entering background - disabling video")
|
||||
if mpv != nil {
|
||||
mpv_set_option_string(mpv, "vid", "no")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func enterForeground() {
|
||||
if isPipActive {
|
||||
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
|
||||
return
|
||||
}
|
||||
|
||||
print("[MpvPlayerCore] Entering foreground - enabling video")
|
||||
if mpv != nil {
|
||||
mpv_set_option_string(mpv, "vid", "auto")
|
||||
}
|
||||
print("[MpvPlayerCore] Entering foreground - enabling video")
|
||||
if mpv != nil {
|
||||
mpv_set_option_string(mpv, "vid", "auto")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,392 +5,416 @@ import AVKit
|
||||
/// Flutter plugin that bridges MPV player to Dart via method and event channels
|
||||
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared {
|
||||
|
||||
// MARK: - Properties
|
||||
// MARK: - Properties
|
||||
|
||||
private var playerCore: MpvPlayerCore?
|
||||
var eventSink: FlutterEventSink?
|
||||
private weak var registrar: FlutterPluginRegistrar?
|
||||
var nameToId: [String: Int] = [:]
|
||||
private var playerCore: MpvPlayerCore?
|
||||
var eventSink: FlutterEventSink?
|
||||
private weak var registrar: FlutterPluginRegistrar?
|
||||
var nameToId: [String: Int] = [:]
|
||||
|
||||
// MpvPluginShared conformance
|
||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||
func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) }
|
||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||
// MpvPluginShared conformance
|
||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||
func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) }
|
||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||
|
||||
// PiP
|
||||
private var pipController: MpvPipController?
|
||||
private var pipChannel: FlutterMethodChannel?
|
||||
private var autoPipEnabled = false
|
||||
private var isManualPipRequest = false
|
||||
private var pipTimebaseSyncTimer: Timer?
|
||||
private var pendingInlineRestoreAfterPip = false
|
||||
private var sceneActivationObserverRegistered = false
|
||||
// PiP
|
||||
private var pipController: MpvPipController?
|
||||
private var pipChannel: FlutterMethodChannel?
|
||||
private var autoPipEnabled = false
|
||||
private var isManualPipRequest = false
|
||||
private var pipTimebaseSyncTimer: Timer?
|
||||
private var pendingInlineRestoreAfterPip = false
|
||||
private var sceneActivationObserverRegistered = false
|
||||
|
||||
// MARK: - FlutterPlugin Registration
|
||||
// MARK: - FlutterPlugin Registration
|
||||
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/mpv_player",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: "com.plezy/mpv_player/events",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let pipChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/pip",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/mpv_player",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: "com.plezy/mpv_player/events",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let pipChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/pip",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
|
||||
let instance = MpvPlayerPlugin()
|
||||
instance.registrar = registrar
|
||||
instance.pipChannel = pipChannel
|
||||
let instance = MpvPlayerPlugin()
|
||||
instance.registrar = registrar
|
||||
instance.pipChannel = pipChannel
|
||||
|
||||
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
||||
eventChannel.setStreamHandler(instance)
|
||||
pipChannel.setMethodCallHandler(instance.handlePipCall)
|
||||
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
||||
eventChannel.setStreamHandler(instance)
|
||||
pipChannel.setMethodCallHandler(instance.handlePipCall)
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||
-> FlutterError?
|
||||
{
|
||||
self.eventSink = events
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
self.eventSink = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - FlutterPlugin Method Handler
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "initialize":
|
||||
handleInitialize(result: result)
|
||||
case "dispose":
|
||||
handleDispose(result: result)
|
||||
case "setProperty":
|
||||
handleSetProperty(call: call, result: result)
|
||||
case "getProperty":
|
||||
handleGetProperty(call: call, result: result)
|
||||
case "observeProperty":
|
||||
handleObserveProperty(call: call, result: result)
|
||||
case "command":
|
||||
handleCommand(call: call, result: result)
|
||||
case "setVisible":
|
||||
handleSetVisible(call: call, result: result)
|
||||
case "isInitialized":
|
||||
result(playerCore?.isInitialized ?? false)
|
||||
case "updateFrame":
|
||||
handleUpdateFrame(result: result)
|
||||
case "setLogLevel":
|
||||
handleSetLogLevel(call: call, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler
|
||||
// MARK: - PiP
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||
self.eventSink = events
|
||||
return nil
|
||||
private func ensurePipController() -> MpvPipController {
|
||||
if let existing = pipController { return existing }
|
||||
let controller = MpvPipController()
|
||||
controller.delegate = self
|
||||
pipController = controller
|
||||
return controller
|
||||
}
|
||||
|
||||
private func registerSceneActivationObserver() {
|
||||
guard !sceneActivationObserverRegistered else { return }
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(sceneDidActivate),
|
||||
name: UIScene.didActivateNotification,
|
||||
object: nil
|
||||
)
|
||||
sceneActivationObserverRegistered = true
|
||||
}
|
||||
|
||||
private func unregisterSceneActivationObserver() {
|
||||
guard sceneActivationObserverRegistered else { return }
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: UIScene.didActivateNotification, object: nil)
|
||||
sceneActivationObserverRegistered = false
|
||||
}
|
||||
|
||||
private var isSceneActive: Bool {
|
||||
UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive }
|
||||
}
|
||||
|
||||
private func restoreInlinePlayerAfterPip() {
|
||||
guard pendingInlineRestoreAfterPip,
|
||||
let playerCore = playerCore,
|
||||
!playerCore.isPipActive,
|
||||
!playerCore.isPipStarting
|
||||
else { return }
|
||||
|
||||
print("[MpvPlayerPlugin] Restoring inline player after PiP")
|
||||
playerCore.setVisible(true)
|
||||
playerCore.updateFrame()
|
||||
if playerCore.isPaused {
|
||||
playerCore.forceDraw()
|
||||
}
|
||||
pendingInlineRestoreAfterPip = false
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
self.eventSink = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - FlutterPlugin Method Handler
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "initialize":
|
||||
handleInitialize(result: result)
|
||||
case "dispose":
|
||||
handleDispose(result: result)
|
||||
case "setProperty":
|
||||
handleSetProperty(call: call, result: result)
|
||||
case "getProperty":
|
||||
handleGetProperty(call: call, result: result)
|
||||
case "observeProperty":
|
||||
handleObserveProperty(call: call, result: result)
|
||||
case "command":
|
||||
handleCommand(call: call, result: result)
|
||||
case "setVisible":
|
||||
handleSetVisible(call: call, result: result)
|
||||
case "isInitialized":
|
||||
result(playerCore?.isInitialized ?? false)
|
||||
case "updateFrame":
|
||||
handleUpdateFrame(result: result)
|
||||
case "setLogLevel":
|
||||
handleSetLogLevel(call: call, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PiP
|
||||
|
||||
private func ensurePipController() -> MpvPipController {
|
||||
if let existing = pipController { return existing }
|
||||
let controller = MpvPipController()
|
||||
controller.delegate = self
|
||||
pipController = controller
|
||||
return controller
|
||||
}
|
||||
|
||||
private func registerSceneActivationObserver() {
|
||||
guard !sceneActivationObserverRegistered else { return }
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(sceneDidActivate),
|
||||
name: UIScene.didActivateNotification,
|
||||
object: nil
|
||||
)
|
||||
sceneActivationObserverRegistered = true
|
||||
}
|
||||
|
||||
private func unregisterSceneActivationObserver() {
|
||||
guard sceneActivationObserverRegistered else { return }
|
||||
NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil)
|
||||
sceneActivationObserverRegistered = false
|
||||
}
|
||||
|
||||
private var isSceneActive: Bool {
|
||||
UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive }
|
||||
}
|
||||
|
||||
private func restoreInlinePlayerAfterPip() {
|
||||
guard pendingInlineRestoreAfterPip,
|
||||
let playerCore = playerCore,
|
||||
!playerCore.isPipActive,
|
||||
!playerCore.isPipStarting else { return }
|
||||
|
||||
print("[MpvPlayerPlugin] Restoring inline player after PiP")
|
||||
playerCore.setVisible(true)
|
||||
playerCore.updateFrame()
|
||||
if playerCore.isPaused {
|
||||
playerCore.forceDraw()
|
||||
}
|
||||
pendingInlineRestoreAfterPip = false
|
||||
}
|
||||
|
||||
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
switch call.method {
|
||||
case "isSupported":
|
||||
result(MpvPipController.isSupported)
|
||||
case "enter":
|
||||
self.enterPip(manual: true, result: result)
|
||||
case "setAutoPipReady":
|
||||
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
|
||||
self.autoPipEnabled = ready
|
||||
if ready {
|
||||
let pip = self.ensurePipController()
|
||||
pip.setAutoStart(true)
|
||||
// Warm the layer so the system considers PiP possible
|
||||
if let pc = self.playerCore {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
} else {
|
||||
self.pipController?.setAutoStart(false)
|
||||
}
|
||||
}
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
switch call.method {
|
||||
case "isSupported":
|
||||
result(MpvPipController.isSupported)
|
||||
case "enter":
|
||||
self.enterPip(manual: true, result: result)
|
||||
case "setAutoPipReady":
|
||||
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
|
||||
self.autoPipEnabled = ready
|
||||
if ready {
|
||||
let pip = self.ensurePipController()
|
||||
pip.setAutoStart(true)
|
||||
// Warm the layer so the system considers PiP possible
|
||||
if let pc = self.playerCore {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
} else {
|
||||
self.pipController?.setAutoStart(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch to PiP VO and prepare the sample buffer layer for PiP display.
|
||||
/// Returns the MpvPipController on success, nil on failure.
|
||||
@discardableResult
|
||||
private func switchToPipAndPrepare() -> MpvPipController? {
|
||||
guard let playerCore = playerCore else { return nil }
|
||||
let pip = ensurePipController()
|
||||
guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil }
|
||||
pendingInlineRestoreAfterPip = false
|
||||
playerCore.isPipStarting = true
|
||||
pip.pushBlankFrame()
|
||||
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
|
||||
pip.invalidatePlaybackState()
|
||||
return pip
|
||||
}
|
||||
|
||||
/// Manual PiP entry (button press). Auto-PiP is handled by the system via
|
||||
/// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate.
|
||||
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
|
||||
guard MpvPipController.isSupported else {
|
||||
result?(["success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+"])
|
||||
return
|
||||
}
|
||||
guard playerCore != nil else {
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "Player not initialized"])
|
||||
return
|
||||
}
|
||||
guard let pip = switchToPipAndPrepare() else {
|
||||
result?(["success": false, "errorCode": "vo_switch_failed", "errorMessage": "Failed to switch VO"])
|
||||
return
|
||||
}
|
||||
|
||||
isManualPipRequest = manual
|
||||
pip.startPip(waitForFrame: manual) { [weak self] started in
|
||||
if started {
|
||||
result?(["success": true])
|
||||
} else {
|
||||
self?.cleanupPip(notify: false)
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "PiP failed to start"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified cleanup for all PiP exit paths
|
||||
private func cleanupPip(notify: Bool, pause: Bool = false) {
|
||||
playerCore?.isPipStarting = false
|
||||
playerCore?.isPipActive = false
|
||||
isManualPipRequest = false
|
||||
stopPipTimebaseSync()
|
||||
pipController?.flushLayer()
|
||||
let restoredInlineVO = playerCore?.switchToGpuNextVO() ?? false
|
||||
if pause { playerCore?.setProperty("pause", value: "yes") }
|
||||
pendingInlineRestoreAfterPip = restoredInlineVO
|
||||
if pendingInlineRestoreAfterPip {
|
||||
if isSceneActive {
|
||||
restoreInlinePlayerAfterPip()
|
||||
} else {
|
||||
print("[MpvPlayerPlugin] Deferring inline restore until scene activation")
|
||||
}
|
||||
}
|
||||
if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) }
|
||||
}
|
||||
|
||||
/// Scene became active — restore inline playback if needed and re-warm the
|
||||
/// sample-buffer layer so future auto-PiP remains possible.
|
||||
@objc private func sceneDidActivate() {
|
||||
restoreInlinePlayerAfterPip()
|
||||
if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timebase Sync
|
||||
|
||||
private func syncPipTimebase() {
|
||||
guard let playerCore = playerCore, let pipController = pipController else { return }
|
||||
pipController.syncTimebase(
|
||||
currentTime: playerCore.timePos,
|
||||
isPlaying: !playerCore.isPaused
|
||||
)
|
||||
}
|
||||
|
||||
private func startPipTimebaseSync() {
|
||||
stopPipTimebaseSync()
|
||||
pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||
self?.syncPipTimebase()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPipTimebaseSync() {
|
||||
pipTimebaseSyncTimer?.invalidate()
|
||||
pipTimebaseSyncTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Platform-Specific Method Handlers
|
||||
|
||||
private func handleInitialize(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
if self.playerCore?.isInitialized == true {
|
||||
self.registerSceneActivationObserver()
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = self.findKeyWindow() else {
|
||||
result(FlutterError(code: "NO_WINDOW", message: "Could not find key window", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let core = MpvPlayerCore()
|
||||
core.delegate = self
|
||||
|
||||
guard core.initialize(in: window) else {
|
||||
result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.playerCore = core
|
||||
self.registerSceneActivationObserver()
|
||||
core.setVisible(false)
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDispose(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
self.pipController?.teardown()
|
||||
self.pipController = nil
|
||||
self.autoPipEnabled = false
|
||||
self.pendingInlineRestoreAfterPip = false
|
||||
self.unregisterSceneActivationObserver()
|
||||
self.stopPipTimebaseSync()
|
||||
self.playerCore?.dispose()
|
||||
self.playerCore = nil
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String,
|
||||
let value = args["value"] as? String else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
playerCore?.setProperty(name, value: value)
|
||||
|
||||
if name == "pause" {
|
||||
pipController?.invalidatePlaybackState()
|
||||
if playerCore?.isPipActive == true { syncPipTimebase() }
|
||||
}
|
||||
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch to PiP VO and prepare the sample buffer layer for PiP display.
|
||||
/// Returns the MpvPipController on success, nil on failure.
|
||||
@discardableResult
|
||||
private func switchToPipAndPrepare() -> MpvPipController? {
|
||||
guard let playerCore = playerCore else { return nil }
|
||||
let pip = ensurePipController()
|
||||
guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil }
|
||||
pendingInlineRestoreAfterPip = false
|
||||
playerCore.isPipStarting = true
|
||||
pip.pushBlankFrame()
|
||||
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
|
||||
pip.invalidatePlaybackState()
|
||||
return pip
|
||||
}
|
||||
|
||||
/// Manual PiP entry (button press). Auto-PiP is handled by the system via
|
||||
/// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate.
|
||||
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
|
||||
guard MpvPipController.isSupported else {
|
||||
result?([
|
||||
"success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+",
|
||||
])
|
||||
return
|
||||
}
|
||||
guard playerCore != nil else {
|
||||
result?([
|
||||
"success": false, "errorCode": "failed", "errorMessage": "Player not initialized",
|
||||
])
|
||||
return
|
||||
}
|
||||
guard let pip = switchToPipAndPrepare() else {
|
||||
result?([
|
||||
"success": false, "errorCode": "vo_switch_failed",
|
||||
"errorMessage": "Failed to switch VO",
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func findKeyWindow() -> UIWindow? {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first(where: { $0.isKeyWindow }) else {
|
||||
return nil
|
||||
}
|
||||
return window
|
||||
isManualPipRequest = manual
|
||||
pip.startPip(waitForFrame: manual) { [weak self] started in
|
||||
if started {
|
||||
result?(["success": true])
|
||||
} else {
|
||||
self?.cleanupPip(notify: false)
|
||||
result?([
|
||||
"success": false, "errorCode": "failed", "errorMessage": "PiP failed to start",
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified cleanup for all PiP exit paths
|
||||
private func cleanupPip(notify: Bool, pause: Bool = false) {
|
||||
playerCore?.isPipStarting = false
|
||||
playerCore?.isPipActive = false
|
||||
isManualPipRequest = false
|
||||
stopPipTimebaseSync()
|
||||
pipController?.flushLayer()
|
||||
let restoredInlineVO = playerCore?.switchToGpuNextVO() ?? false
|
||||
if pause { playerCore?.setProperty("pause", value: "yes") }
|
||||
pendingInlineRestoreAfterPip = restoredInlineVO
|
||||
if pendingInlineRestoreAfterPip {
|
||||
if isSceneActive {
|
||||
restoreInlinePlayerAfterPip()
|
||||
} else {
|
||||
print("[MpvPlayerPlugin] Deferring inline restore until scene activation")
|
||||
}
|
||||
}
|
||||
if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) }
|
||||
}
|
||||
|
||||
/// Scene became active — restore inline playback if needed and re-warm the
|
||||
/// sample-buffer layer so future auto-PiP remains possible.
|
||||
@objc private func sceneDidActivate() {
|
||||
restoreInlinePlayerAfterPip()
|
||||
if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timebase Sync
|
||||
|
||||
private func syncPipTimebase() {
|
||||
guard let playerCore = playerCore, let pipController = pipController else { return }
|
||||
pipController.syncTimebase(
|
||||
currentTime: playerCore.timePos,
|
||||
isPlaying: !playerCore.isPaused
|
||||
)
|
||||
}
|
||||
|
||||
private func startPipTimebaseSync() {
|
||||
stopPipTimebaseSync()
|
||||
pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) {
|
||||
[weak self] _ in
|
||||
self?.syncPipTimebase()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPipTimebaseSync() {
|
||||
pipTimebaseSyncTimer?.invalidate()
|
||||
pipTimebaseSyncTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Platform-Specific Method Handlers
|
||||
|
||||
private func handleInitialize(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
if self.playerCore?.isInitialized == true {
|
||||
self.registerSceneActivationObserver()
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = self.findKeyWindow() else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "NO_WINDOW", message: "Could not find key window", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let core = MpvPlayerCore()
|
||||
core.delegate = self
|
||||
|
||||
guard core.initialize(in: window) else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.playerCore = core
|
||||
self.registerSceneActivationObserver()
|
||||
core.setVisible(false)
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDispose(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
self.pipController?.teardown()
|
||||
self.pipController = nil
|
||||
self.autoPipEnabled = false
|
||||
self.pendingInlineRestoreAfterPip = false
|
||||
self.unregisterSceneActivationObserver()
|
||||
self.stopPipTimebaseSync()
|
||||
self.playerCore?.dispose()
|
||||
self.playerCore = nil
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String,
|
||||
let value = args["value"] as? String
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument",
|
||||
details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
playerCore?.setProperty(name, value: value)
|
||||
|
||||
if name == "pause" {
|
||||
pipController?.invalidatePlaybackState()
|
||||
if playerCore?.isPipActive == true { syncPipTimebase() }
|
||||
}
|
||||
|
||||
result(nil)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func findKeyWindow() -> UIWindow? {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first(where: { $0.isKeyWindow })
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MpvPipDelegate
|
||||
|
||||
extension MpvPlayerPlugin: MpvPipDelegate {
|
||||
|
||||
func pipWillStart() {
|
||||
// If PiP was system-initiated (not via our enterPip), switch VO 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")
|
||||
pipController?.stopPip()
|
||||
}
|
||||
func pipWillStart() {
|
||||
// If PiP was system-initiated (not via our enterPip), switch VO 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")
|
||||
pipController?.stopPip()
|
||||
}
|
||||
}
|
||||
|
||||
func pipDidStart() {
|
||||
playerCore?.isPipStarting = false
|
||||
playerCore?.isPipActive = true
|
||||
pendingInlineRestoreAfterPip = false
|
||||
pipChannel?.invokeMethod("onPipChanged", arguments: true)
|
||||
syncPipTimebase()
|
||||
startPipTimebaseSync()
|
||||
func pipDidStart() {
|
||||
playerCore?.isPipStarting = false
|
||||
playerCore?.isPipActive = true
|
||||
pendingInlineRestoreAfterPip = false
|
||||
pipChannel?.invokeMethod("onPipChanged", arguments: true)
|
||||
syncPipTimebase()
|
||||
startPipTimebaseSync()
|
||||
|
||||
if isManualPipRequest {
|
||||
isManualPipRequest = false
|
||||
UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
|
||||
}
|
||||
if isManualPipRequest {
|
||||
isManualPipRequest = false
|
||||
UIControl().sendAction(
|
||||
#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func pipDidStop(restored: Bool) {
|
||||
cleanupPip(notify: true, pause: !restored)
|
||||
func pipDidStop(restored: Bool) {
|
||||
cleanupPip(notify: true, pause: !restored)
|
||||
}
|
||||
|
||||
func pipDidFailToStart(error: Error?) {
|
||||
cleanupPip(notify: true)
|
||||
}
|
||||
|
||||
func pipSetPlaying(_ playing: Bool) {
|
||||
playerCore?.setProperty("pause", value: playing ? "no" : "yes")
|
||||
pipController?.invalidatePlaybackState()
|
||||
syncPipTimebase()
|
||||
}
|
||||
|
||||
func pipSkip(byInterval seconds: Double) {
|
||||
guard let playerCore = playerCore else { return }
|
||||
let newTime = max(0, playerCore.timePos + seconds)
|
||||
playerCore.command(["seek", String(newTime), "absolute"])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.syncPipTimebase()
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
|
||||
func pipDidFailToStart(error: Error?) {
|
||||
cleanupPip(notify: true)
|
||||
}
|
||||
|
||||
func pipSetPlaying(_ playing: Bool) {
|
||||
playerCore?.setProperty("pause", value: playing ? "no" : "yes")
|
||||
pipController?.invalidatePlaybackState()
|
||||
syncPipTimebase()
|
||||
}
|
||||
|
||||
func pipSkip(byInterval seconds: Double) {
|
||||
guard let playerCore = playerCore else { return }
|
||||
let newTime = max(0, playerCore.timePos + seconds)
|
||||
playerCore.command(["seek", String(newTime), "absolute"])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.syncPipTimebase()
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
|
||||
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
|
||||
var pipDuration: Double { playerCore?.duration ?? 0 }
|
||||
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
|
||||
var pipDuration: Double { playerCore?.duration ?? 0 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user