feat(ios): add picture-in-picture support

close #44
This commit is contained in:
edde746
2026-03-02 14:06:11 +01:00
parent 5f1c51ff6c
commit a008f86ee2
24 changed files with 765 additions and 91 deletions
+363
View File
@@ -0,0 +1,363 @@
import AVKit
import UIKit
/// 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()
/// Called when PiP stops. `restored` is true if the user pressed maximize (restore UI).
func pipDidStop(restored: Bool)
func pipDidFailToStart(error: Error?)
/// Forward play/pause commands from PiP overlay to mpv
func pipSetPlaying(_ playing: Bool)
/// Forward skip forward/backward commands from PiP overlay to mpv
func pipSkip(byInterval seconds: Double)
/// Query whether mpv is currently playing
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 {
// MARK: - Properties
private var pipController: AVPictureInPictureController?
private let sampleBufferLayer = AVSampleBufferDisplayLayer()
private var containerView: UIView?
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() {
super.init()
setup()
}
private func setup() {
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)")
}
// 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.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.clipsToBounds = true
view.isUserInteractionEnabled = false
sampleBufferLayer.frame = view.bounds
view.layer.addSublayer(sampleBufferLayer)
window.addSubview(view)
containerView = view
}
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
}
}
/// 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
}
/// 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 }
// 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 }
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
}
}
syncTimebase(currentTime: currentTime, isPlaying: isPlaying)
pushBlankFrame()
}
// MARK: - Public API
static var isSupported: Bool {
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
}
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)
}
}
tryStart()
}
func stopPip() {
pipController?.stopPictureInPicture()
}
/// Invalidate the playback state so PiP updates its UI (play/pause button)
func 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.removeFromSuperlayer()
containerView?.removeFromSuperview()
containerView = nil
}
}
// 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,
AVPictureInPictureSampleBufferPlaybackDelegate
{
weak var controller: MpvPipController?
private var isRestoring = false
init(controller: MpvPipController) {
self.controller = controller
super.init()
}
// MARK: - AVPictureInPictureControllerDelegate
func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP will start")
controller?.delegate?.pipWillStart()
}
func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP did start")
controller?.delegate?.pipDidStart()
}
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
let restored = isRestoring
isRestoring = false
print("[MpvPipController] PiP did stop (restored: \(restored))")
controller?.delegate?.pipDidStop(restored: restored)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
print("[MpvPipController] PiP failed to start: \(error)")
controller?.delegate?.pipDidFailToStart(error: error)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
print("[MpvPipController] PiP restore user interface")
isRestoring = true
completionHandler(true)
}
func pictureInPictureControllerWillStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP will stop")
}
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool
) {
print("[MpvPipController] PiP setPlaying: \(playing)")
controller?.delegate?.pipSetPlaying(playing)
}
func pictureInPictureControllerTimeRangeForPlayback(
_ 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))
}
func pictureInPictureControllerIsPlaybackPaused(
_ pictureInPictureController: AVPictureInPictureController
) -> Bool {
return !(controller?.delegate?.isPipPlaying ?? false)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions
) {}
func pictureInPictureController(
_ 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()
}
}
+105 -7
View File
@@ -51,6 +51,10 @@ class MpvPlayerCore: NSObject {
private(set) var isInitialized = false
private var isDisposing = false // Flag to prevent race conditions during disposal
// PiP state
var isPipActive = false
var isPipStarting = false // VO switched for PiP, waiting for first frame
// HDR settings
private var hdrEnabled = true // User preference for HDR
private var lastSigPeak: Double = 0.0 // Last known sig-peak for re-evaluation
@@ -90,9 +94,6 @@ class MpvPlayerCore: NSObject {
// Add container view to window (behind Flutter's root view controller)
window.insertSubview(container, at: 0)
print(
"[MpvPlayerCore] Metal layer added to window via container view, frame: \(layer.frame)")
// Initialize MPV with this Metal layer
guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
@@ -161,7 +162,6 @@ class MpvPlayerCore: NSObject {
// Observe video-params/sig-peak for HDR detection
mpv_observe_property(mpv, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE)
print("[MpvPlayerCore] MPV initialized successfully")
return true
}
@@ -183,6 +183,10 @@ class MpvPlayerCore: NSObject {
}
@objc private func enterBackground() {
if isPipActive || isPipStarting {
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
return
}
// Disable video output to fix black screen when returning from background
print("[MpvPlayerCore] Entering background - disabling video")
if mpv != nil {
@@ -191,6 +195,11 @@ class MpvPlayerCore: NSObject {
}
@objc private func enterForeground() {
// Skip if PiP is active - video is already enabled
if isPipActive {
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
return
}
// Re-enable video output
print("[MpvPlayerCore] Entering foreground - enabling video")
if mpv != nil {
@@ -305,6 +314,98 @@ class MpvPlayerCore: NSObject {
}
}
// MARK: - PiP VO Switching
/// Switch from gpu-next to vo_pip for PiP rendering.
/// vo_pip feeds VideoToolbox CVPixelBuffers to an AVSampleBufferDisplayLayer
/// which is required by AVPictureInPictureController.
///
/// Strategy: disable the video track (`vid=no`) to tear down the active VO,
/// then set `wid` and `vo` while no VO is running, then re-enable video.
/// This avoids the crash where gpu-next calls `drawableSize` on an
/// AVSampleBufferDisplayLayer, and ensures the new VO reads `wid` at init.
func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool {
guard let mpv = mpv else { return false }
print("[MpvPlayerCore] Switching to pip VO for PiP")
// Detach the Metal layer from the view hierarchy on the main thread BEFORE
// vid=no, so the 'vo' thread VO uninit has no UIKit work to do (avoids
// "layout engine from background thread" crash).
metalLayer?.removeFromSuperlayer()
// 1. Disable video track tears down the current VO completely
mpv_set_property_string(mpv, "vid", "no")
// 2. Point wid at the PiP sample-buffer layer (safe no active VO)
var ptr = Int64(Int(bitPattern: layerPtr))
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &ptr)
// 3. Set VO to pip (stored, not yet created)
mpv_set_property_string(mpv, "vo", "pip")
// 4. Re-enable video creates pip VO with our layer as wid
mpv_set_property_string(mpv, "vid", "auto")
print("[MpvPlayerCore] Switched to pip VO successfully")
return true
}
/// Switch back from vo_pip to gpu-next VO for normal rendering.
/// Same strategy: vid=no reconfigure vid=auto.
func switchToGpuNextVO() -> Bool {
guard let mpv = mpv, let metalLayer = metalLayer else { return false }
print("[MpvPlayerCore] Switching back to gpu-next VO")
// 1. Disable video track tears down pip VO
mpv_set_property_string(mpv, "vid", "no")
// 2. Point wid back at the Metal layer (safe no active VO)
var layer = metalLayer
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer)
// 3. Restore gpu-next + Vulkan/MoltenVK settings (read at VO creation)
mpv_set_property_string(mpv, "gpu-api", "vulkan")
mpv_set_property_string(mpv, "gpu-context", "moltenvk")
mpv_set_property_string(mpv, "vo", "gpu-next")
// 4. Re-enable video creates gpu-next VO with Metal layer as wid
mpv_set_property_string(mpv, "vid", "auto")
// Re-attach the Metal layer to the view hierarchy (was detached in switchToPipVO)
if metalLayer.superlayer == nil, let container = containerView {
container.layer.addSublayer(metalLayer)
}
print("[MpvPlayerCore] Switched back to gpu-next VO successfully")
return true
}
/// Whether mpv is currently paused
var isPaused: Bool {
guard let mpv = mpv else { return true }
var flag: Int32 = 0
mpv_get_property(mpv, "pause", MPV_FORMAT_FLAG, &flag)
return flag != 0
}
/// Current playback duration in seconds
var duration: Double {
guard let mpv = mpv else { return 0 }
var value: Double = 0
mpv_get_property(mpv, "duration", MPV_FORMAT_DOUBLE, &value)
return value
}
/// Current playback time in seconds
var timePos: Double {
guard let mpv = mpv else { return 0 }
var value: Double = 0
mpv_get_property(mpv, "time-pos", MPV_FORMAT_DOUBLE, &value)
return value
}
// MARK: - Visibility
func setVisible(_ visible: Bool) {
@@ -317,7 +418,6 @@ class MpvPlayerCore: NSObject {
}
container.isHidden = !visible
print("[MpvPlayerCore] setVisible(\(visible))")
}
func updateFrame(_ frame: CGRect? = nil) {
@@ -337,8 +437,6 @@ class MpvPlayerCore: NSObject {
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
)
print("[MpvPlayerCore] updateFrame: \(container.frame)")
}
// MARK: - Private Helpers
+208 -50
View File
@@ -1,5 +1,6 @@
import UIKit
import Flutter
import AVKit
/// Flutter plugin that bridges MPV player to Dart via method and event channels
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerDelegate {
@@ -11,41 +12,47 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
private weak var registrar: FlutterPluginRegistrar?
private var nameToId: [String: Int] = [:]
// PiP
private var pipController: MpvPipController?
private var pipChannel: FlutterMethodChannel?
private var autoPipEnabled = false
private var isManualPipRequest = false
private var pipTimebaseSyncTimer: Timer?
// MARK: - FlutterPlugin Registration
static func register(with registrar: FlutterPluginRegistrar) {
// Method channel for commands
let methodChannel = FlutterMethodChannel(
name: "com.plezy/mpv_player",
binaryMessenger: registrar.messenger()
)
// Event channel for state updates
let eventChannel = FlutterEventChannel(
name: "com.plezy/mpv_player/events",
binaryMessenger: registrar.messenger()
)
let pipChannel = FlutterMethodChannel(
name: "app.plezy/pip",
binaryMessenger: registrar.messenger()
)
let instance = MpvPlayerPlugin()
instance.registrar = registrar
instance.pipChannel = pipChannel
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
print("[MpvPlayerPlugin] Registered with Flutter")
pipChannel.setMethodCallHandler(instance.handlePipCall)
}
// MARK: - FlutterStreamHandler
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
print("[MpvPlayerPlugin] Event stream connected")
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
print("[MpvPlayerPlugin] Event stream disconnected")
return nil
}
@@ -55,39 +62,149 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
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 handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "isSupported":
result(MpvPipController.isSupported)
case "enter":
enterPip(manual: true, result: result)
case "setAutoPipReady":
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
autoPipEnabled = ready
if ready {
let pip = ensurePipController()
pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = playerCore {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sceneDidActivate), name: UIScene.didActivateNotification, object: nil)
} else {
pipController?.setAutoStart(false)
NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil)
}
}
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 }
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()
_ = playerCore?.switchToGpuNextVO()
if pause { playerCore?.setProperty("pause", value: "yes") }
if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) }
}
/// Scene became active re-warm the layer so auto-PiP stays possible
@objc private func sceneDidActivate() {
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: - Method Handlers
private func handleInitialize(result: @escaping FlutterResult) {
@@ -97,45 +214,40 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
return
}
// Check if already initialized
if self.playerCore?.isInitialized == true {
print("[MpvPlayerPlugin] Already initialized")
result(true)
return
}
// Find the key window
guard let window = self.findKeyWindow() else {
print("[MpvPlayerPlugin] Failed to find key window")
result(FlutterError(code: "NO_WINDOW", message: "Could not find key window", details: nil))
return
}
// Create and initialize player core
let core = MpvPlayerCore()
core.delegate = self
guard core.initialize(in: window) else {
print("[MpvPlayerPlugin] Failed to initialize MPV")
result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
return
}
self.playerCore = core
// Start hidden
core.setVisible(false)
print("[MpvPlayerPlugin] Initialized successfully")
result(true)
}
}
private func handleDispose(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
self?.playerCore?.dispose()
self?.playerCore = nil
print("[MpvPlayerPlugin] Disposed")
guard let self = self else { result(nil); return }
self.pipController?.teardown()
self.pipController = nil
self.autoPipEnabled = false
NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil)
self.stopPipTimebaseSync()
self.playerCore?.dispose()
self.playerCore = nil
result(nil)
}
}
@@ -149,6 +261,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
}
playerCore?.setProperty(name, value: value)
if name == "pause" {
pipController?.invalidatePlaybackState()
if playerCore?.isPipActive == true { syncPipTimebase() }
}
result(nil)
}
@@ -158,9 +276,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' argument", details: nil))
return
}
let value = playerCore?.getProperty(name)
result(value)
result(playerCore?.getProperty(name))
}
private func handleObserveProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
@@ -184,7 +300,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
return
}
// Use async command to prevent UI blocking during network operations
playerCore?.commandAsync(commandArgs) { commandResult in
switch commandResult {
case .success:
@@ -204,12 +319,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
DispatchQueue.main.async { [weak self] in
self?.playerCore?.setVisible(visible)
// Update frame when becoming visible
if visible {
self?.playerCore?.updateFrame()
}
if visible { self?.playerCore?.updateFrame() }
result(nil)
}
}
@@ -234,21 +344,14 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
// MARK: - MpvPlayerDelegate
func onPropertyChange(name: String, value: Any?) {
guard let eventSink = eventSink else { return }
if let propId = nameToId[name] {
eventSink([propId, value as Any])
}
guard let eventSink = eventSink, let propId = nameToId[name] else { return }
eventSink([propId, value as Any])
}
func onEvent(name: String, data: [String: Any]?) {
guard let eventSink = eventSink else { return }
var event: [String: Any] = ["type": "event", "name": name]
if let data = data {
event["data"] = data
}
if let data = data { event["data"] = data }
eventSink(event)
}
@@ -262,3 +365,58 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
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 pipDidStart() {
playerCore?.isPipStarting = false
playerCore?.isPipActive = true
pipChannel?.invokeMethod("onPipChanged", arguments: true)
syncPipTimebase()
startPipTimebaseSync()
if isManualPipRequest {
isManualPipRequest = false
UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
}
}
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()
}
}
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
var pipDuration: Double { playerCore?.duration ?? 0 }
}