fix(native): bound cross-platform lifecycle ownership
This commit is contained in:
@@ -54,6 +54,20 @@ import UIKit
|
||||
/// Get total duration in seconds
|
||||
var pipDuration: Double { get }
|
||||
}
|
||||
protocol MpvPictureInPictureControlling: AnyObject {
|
||||
var isPictureInPicturePossible: Bool { get }
|
||||
func startPictureInPicture()
|
||||
func stopPictureInPicture()
|
||||
func setAutomaticStart(_ enabled: Bool)
|
||||
func invalidatePlaybackState()
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
extension AVPictureInPictureController: MpvPictureInPictureControlling {
|
||||
func setAutomaticStart(_ enabled: Bool) {
|
||||
canStartPictureInPictureAutomaticallyFromInline = enabled
|
||||
}
|
||||
}
|
||||
|
||||
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
|
||||
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
|
||||
@@ -61,18 +75,69 @@ import UIKit
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var pipController: AVPictureInPictureController?
|
||||
private var pipController: MpvPictureInPictureControlling?
|
||||
private weak var sampleBufferLayer: AVSampleBufferDisplayLayer?
|
||||
weak var delegate: MpvPipDelegate?
|
||||
private var startGeneration = 0
|
||||
private var pendingStartCompletion: ((Bool) -> Void)?
|
||||
private var startRequested = false
|
||||
private var systemStartExpected = false
|
||||
private var hasActiveSession = false
|
||||
private var restoreRequested = false
|
||||
private var isTornDown = false
|
||||
private let readinessOverride: (() -> (possible: Bool, timebase: Bool, frame: Bool))?
|
||||
private let retryScheduler: (@escaping () -> Void) -> Void
|
||||
private let startTimeoutScheduler: (@escaping () -> Void) -> Void
|
||||
private let replacementControllerFactory: ((AVSampleBufferDisplayLayer?) -> MpvPictureInPictureControlling)?
|
||||
private var autoStartEnabled = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
||||
self.sampleBufferLayer = sampleBufferDisplayLayer
|
||||
self.readinessOverride = nil
|
||||
self.retryScheduler = { work in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
|
||||
}
|
||||
self.startTimeoutScheduler = { work in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work)
|
||||
}
|
||||
self.replacementControllerFactory = nil
|
||||
super.init()
|
||||
setup()
|
||||
}
|
||||
|
||||
init(
|
||||
controller: MpvPictureInPictureControlling,
|
||||
sampleBufferDisplayLayer: AVSampleBufferDisplayLayer? = nil,
|
||||
readiness: @escaping () -> (possible: Bool, timebase: Bool, frame: Bool),
|
||||
retryScheduler: @escaping (@escaping () -> Void) -> Void,
|
||||
startTimeoutScheduler: @escaping (@escaping () -> Void) -> Void = { work in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work)
|
||||
},
|
||||
replacementControllerFactory:
|
||||
((AVSampleBufferDisplayLayer?) -> MpvPictureInPictureControlling)? = nil
|
||||
) {
|
||||
self.pipController = controller
|
||||
self.sampleBufferLayer = sampleBufferDisplayLayer
|
||||
self.readinessOverride = readiness
|
||||
self.retryScheduler = retryScheduler
|
||||
self.startTimeoutScheduler = startTimeoutScheduler
|
||||
self.replacementControllerFactory = replacementControllerFactory
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let completion = pendingStartCompletion {
|
||||
pendingStartCompletion = nil
|
||||
if Thread.isMainThread {
|
||||
completion(false)
|
||||
} else {
|
||||
DispatchQueue.main.async { completion(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
|
||||
@@ -98,14 +163,17 @@ import UIKit
|
||||
)
|
||||
self.delegateHelper = helper
|
||||
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||
pipController?.delegate = helper
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
(pipController as? AVPictureInPictureController)?.delegate = helper
|
||||
pipController?.setAutomaticStart(autoStartEnabled)
|
||||
}
|
||||
|
||||
/// 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 !isTornDown else { return }
|
||||
let wasEnabled = autoStartEnabled
|
||||
autoStartEnabled = enabled
|
||||
if enabled && !wasEnabled { systemStartExpected = false }
|
||||
pipController?.setAutomaticStart(enabled)
|
||||
}
|
||||
|
||||
/// MPVKit owns the sample-buffer layer timebase. PiP only reads it.
|
||||
@@ -126,63 +194,219 @@ import UIKit
|
||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
/// Start PiP. When `waitForFrame` is false (auto-PiP), skips the frame
|
||||
/// readiness check since the scene is about to deactivate.
|
||||
fileprivate func isCurrentController(_ controller: MpvPictureInPictureControlling) -> Bool {
|
||||
guard let pipController else { return false }
|
||||
return ObjectIdentifier(pipController) == ObjectIdentifier(controller)
|
||||
}
|
||||
|
||||
private func retireCurrentPipController() {
|
||||
if #available(iOS 15.0, *) {
|
||||
(delegateHelper as? PipDelegateHelper)?.controller = nil
|
||||
(pipController as? AVPictureInPictureController)?.delegate = nil
|
||||
}
|
||||
pipController?.setAutomaticStart(false)
|
||||
pipController?.stopPictureInPicture()
|
||||
pipController = nil
|
||||
delegateHelper = nil
|
||||
}
|
||||
|
||||
private func recreatePipController() {
|
||||
if let replacementControllerFactory {
|
||||
pipController = replacementControllerFactory(sampleBufferLayer)
|
||||
pipController?.setAutomaticStart(autoStartEnabled)
|
||||
} else if sampleBufferLayer != nil {
|
||||
createPipController()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishStart(generation: Int, success: Bool) {
|
||||
guard generation == startGeneration, let completion = pendingStartCompletion else { return }
|
||||
pendingStartCompletion = nil
|
||||
startRequested = false
|
||||
completion(success)
|
||||
}
|
||||
|
||||
private func cancelPendingStart() {
|
||||
startGeneration &+= 1
|
||||
startRequested = false
|
||||
guard let completion = pendingStartCompletion else { return }
|
||||
pendingStartCompletion = nil
|
||||
completion(false)
|
||||
}
|
||||
|
||||
private func readiness(waitForFrame: Bool) -> (possible: Bool, timebase: Bool, frame: Bool) {
|
||||
if let readinessOverride {
|
||||
return readinessOverride()
|
||||
}
|
||||
let possible = pipController?.isPictureInPicturePossible ?? false
|
||||
let timebase = sampleBufferLayer?.controlTimebase != nil
|
||||
let frame: Bool
|
||||
if !waitForFrame {
|
||||
frame = true
|
||||
} else if #available(iOS 17.4, *) {
|
||||
frame = sampleBufferLayer?.isReadyForDisplay ?? false
|
||||
} else {
|
||||
frame = true
|
||||
}
|
||||
return (possible, timebase, frame)
|
||||
}
|
||||
|
||||
private func scheduleStartTimeout(
|
||||
generation: Int,
|
||||
controllerIdentifier: ObjectIdentifier
|
||||
) {
|
||||
startTimeoutScheduler { [weak self] in
|
||||
guard let self, !isTornDown, generation == startGeneration,
|
||||
startRequested, let completion = pendingStartCompletion,
|
||||
let pipController,
|
||||
ObjectIdentifier(pipController) == controllerIdentifier
|
||||
else { return }
|
||||
print("[MpvPipController] PiP start produced no delegate outcome before the deadline")
|
||||
pendingStartCompletion = nil
|
||||
startRequested = false
|
||||
systemStartExpected = false
|
||||
retireCurrentPipController()
|
||||
recreatePipController()
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func retryStart(generation: Int, waitForFrame: Bool, attempts: Int) {
|
||||
guard !isTornDown, generation == startGeneration, pendingStartCompletion != nil,
|
||||
let pipController
|
||||
else { return }
|
||||
|
||||
let readiness = readiness(waitForFrame: waitForFrame)
|
||||
if readiness.possible && readiness.timebase && readiness.frame {
|
||||
guard !startRequested else { return }
|
||||
startRequested = true
|
||||
print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP")
|
||||
pipController.startPictureInPicture()
|
||||
scheduleStartTimeout(
|
||||
generation: generation,
|
||||
controllerIdentifier: ObjectIdentifier(pipController)
|
||||
)
|
||||
} else if attempts < 40 {
|
||||
retryScheduler { [weak self] in
|
||||
self?.retryStart(
|
||||
generation: generation, waitForFrame: waitForFrame, attempts: attempts + 1)
|
||||
}
|
||||
} else {
|
||||
print(
|
||||
"[MpvPipController] PiP not ready after \(attempts) retries "
|
||||
+ "(possible=\(readiness.possible), timebase=\(readiness.timebase))"
|
||||
)
|
||||
finishStart(generation: generation, success: false)
|
||||
}
|
||||
}
|
||||
|
||||
func pictureInPictureWillStart(from controller: MpvPictureInPictureControlling) {
|
||||
guard !isTornDown, isCurrentController(controller) else { return }
|
||||
systemStartExpected = true
|
||||
delegate?.pipWillStart()
|
||||
}
|
||||
|
||||
func pictureInPictureWillStart() {
|
||||
guard let pipController else { return }
|
||||
pictureInPictureWillStart(from: pipController)
|
||||
}
|
||||
|
||||
func pictureInPictureDidStart(from controller: MpvPictureInPictureControlling) {
|
||||
guard !isTornDown, isCurrentController(controller) else { return }
|
||||
guard systemStartExpected || pendingStartCompletion != nil else {
|
||||
controller.stopPictureInPicture()
|
||||
return
|
||||
}
|
||||
hasActiveSession = true
|
||||
systemStartExpected = false
|
||||
// Resolve the pending manual method call before the delegate publishes
|
||||
// PiP state: the plugin's delegate path may suspend the application.
|
||||
finishStart(generation: startGeneration, success: true)
|
||||
delegate?.pipDidStart()
|
||||
}
|
||||
|
||||
func pictureInPictureDidStart() {
|
||||
guard let pipController else { return }
|
||||
pictureInPictureDidStart(from: pipController)
|
||||
}
|
||||
|
||||
func pictureInPictureFailedToStart(
|
||||
from controller: MpvPictureInPictureControlling,
|
||||
error: Error
|
||||
) {
|
||||
guard !isTornDown, isCurrentController(controller),
|
||||
systemStartExpected || pendingStartCompletion != nil
|
||||
else { return }
|
||||
systemStartExpected = false
|
||||
delegate?.pipDidFailToStart(error: error)
|
||||
finishStart(generation: startGeneration, success: false)
|
||||
}
|
||||
|
||||
func pictureInPictureFailedToStart(error: Error) {
|
||||
guard let pipController else { return }
|
||||
pictureInPictureFailedToStart(from: pipController, error: error)
|
||||
}
|
||||
|
||||
func pictureInPictureDidStop(from controller: MpvPictureInPictureControlling) {
|
||||
guard !isTornDown, isCurrentController(controller), hasActiveSession else { return }
|
||||
hasActiveSession = false
|
||||
let restored = restoreRequested
|
||||
restoreRequested = false
|
||||
delegate?.pipDidStop(restored: restored)
|
||||
}
|
||||
|
||||
func pictureInPictureDidStop() {
|
||||
guard let pipController else { return }
|
||||
pictureInPictureDidStop(from: pipController)
|
||||
}
|
||||
|
||||
func restoreUserInterface(completion: @escaping (Bool) -> Void) {
|
||||
let canRestore = !isTornDown && hasActiveSession && delegate != nil
|
||||
restoreRequested = canRestore
|
||||
completion(canRestore)
|
||||
}
|
||||
|
||||
/// Start PiP. Completion reports the delegate-confirmed terminal outcome.
|
||||
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
|
||||
guard let pipController = pipController else {
|
||||
guard !isTornDown, pipController != nil else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
var attempts = 0
|
||||
func tryStart() {
|
||||
let possible = pipController.isPictureInPicturePossible
|
||||
let hasTimebase = self.sampleBufferLayer?.controlTimebase != nil
|
||||
|
||||
let hasFrame: Bool
|
||||
if !waitForFrame {
|
||||
hasFrame = true // Skip frame check for auto-PiP
|
||||
} else if #available(iOS 17.4, *) {
|
||||
hasFrame = self.sampleBufferLayer?.isReadyForDisplay ?? false
|
||||
} else {
|
||||
hasFrame = true
|
||||
}
|
||||
|
||||
if possible && hasTimebase && hasFrame {
|
||||
print("[MpvPipController] vo_avfoundation 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)
|
||||
}
|
||||
guard pendingStartCompletion == nil else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
tryStart()
|
||||
startGeneration &+= 1
|
||||
let generation = startGeneration
|
||||
pendingStartCompletion = completion
|
||||
startRequested = false
|
||||
systemStartExpected = false
|
||||
retryStart(generation: generation, waitForFrame: waitForFrame, attempts: 0)
|
||||
}
|
||||
|
||||
func stopPip() {
|
||||
cancelPendingStart()
|
||||
systemStartExpected = false
|
||||
restoreRequested = false
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
/// Invalidate the playback state so PiP updates its UI (play/pause button)
|
||||
func invalidatePlaybackState() {
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
guard !isTornDown else { return }
|
||||
pipController?.invalidatePlaybackState()
|
||||
}
|
||||
|
||||
/// Fully tear down PiP without touching the shared inline display layer.
|
||||
func teardown() {
|
||||
pipController?.stopPictureInPicture()
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
}
|
||||
pipController = nil
|
||||
delegateHelper = nil
|
||||
guard !isTornDown else { return }
|
||||
cancelPendingStart()
|
||||
isTornDown = true
|
||||
systemStartExpected = false
|
||||
hasActiveSession = false
|
||||
restoreRequested = false
|
||||
retireCurrentPipController()
|
||||
delegate = nil
|
||||
}
|
||||
|
||||
}
|
||||
@@ -197,7 +421,6 @@ import UIKit
|
||||
AVPictureInPictureSampleBufferPlaybackDelegate
|
||||
{
|
||||
weak var controller: MpvPipController?
|
||||
private var isRestoring = false
|
||||
|
||||
init(controller: MpvPipController) {
|
||||
self.controller = controller
|
||||
@@ -210,23 +433,21 @@ import UIKit
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
print("[MpvPipController] PiP will start")
|
||||
controller?.delegate?.pipWillStart()
|
||||
controller?.pictureInPictureWillStart(from: pictureInPictureController)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
print("[MpvPipController] PiP did start")
|
||||
controller?.delegate?.pipDidStart()
|
||||
controller?.pictureInPictureDidStart(from: pictureInPictureController)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
let restored = isRestoring
|
||||
isRestoring = false
|
||||
print("[MpvPipController] PiP did stop (restored: \(restored))")
|
||||
controller?.delegate?.pipDidStop(restored: restored)
|
||||
print("[MpvPipController] PiP did stop")
|
||||
controller?.pictureInPictureDidStop(from: pictureInPictureController)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
@@ -234,7 +455,10 @@ import UIKit
|
||||
failedToStartPictureInPictureWithError error: Error
|
||||
) {
|
||||
print("[MpvPipController] PiP failed to start: \(error)")
|
||||
controller?.delegate?.pipDidFailToStart(error: error)
|
||||
controller?.pictureInPictureFailedToStart(
|
||||
from: pictureInPictureController,
|
||||
error: error
|
||||
)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
@@ -243,30 +467,43 @@ import UIKit
|
||||
@escaping (Bool) -> Void
|
||||
) {
|
||||
print("[MpvPipController] PiP restore user interface")
|
||||
isRestoring = true
|
||||
completionHandler(true)
|
||||
guard let controller,
|
||||
controller.isCurrentController(pictureInPictureController)
|
||||
else {
|
||||
completionHandler(false)
|
||||
return
|
||||
}
|
||||
controller.restoreUserInterface(completion: completionHandler)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
guard controller?.isCurrentController(pictureInPictureController) == true else { return }
|
||||
print("[MpvPipController] PiP will stop")
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
setPlaying playing: Bool
|
||||
) {
|
||||
guard let controller,
|
||||
controller.isCurrentController(pictureInPictureController)
|
||||
else { return }
|
||||
print("[MpvPipController] PiP setPlaying: \(playing)")
|
||||
controller?.delegate?.pipSetPlaying(playing)
|
||||
controller.delegate?.pipSetPlaying(playing)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerTimeRangeForPlayback(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) -> CMTimeRange {
|
||||
let duration = controller?.delegate?.pipDuration ?? 0
|
||||
guard let controller,
|
||||
controller.isCurrentController(pictureInPictureController)
|
||||
else {
|
||||
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
|
||||
}
|
||||
let duration = controller.delegate?.pipDuration ?? 0
|
||||
if duration > 0 {
|
||||
return CMTimeRange(
|
||||
start: .zero,
|
||||
@@ -279,7 +516,10 @@ import UIKit
|
||||
func pictureInPictureControllerIsPlaybackPaused(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) -> Bool {
|
||||
return !(controller?.delegate?.isPipPlaying ?? false)
|
||||
guard let controller,
|
||||
controller.isCurrentController(pictureInPictureController)
|
||||
else { return true }
|
||||
return !(controller.delegate?.isPipPlaying ?? false)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
@@ -292,9 +532,15 @@ import UIKit
|
||||
skipByInterval skipInterval: CMTime,
|
||||
completion completionHandler: @escaping () -> Void
|
||||
) {
|
||||
guard let controller,
|
||||
controller.isCurrentController(pictureInPictureController)
|
||||
else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
let seconds = CMTimeGetSeconds(skipInterval)
|
||||
print("[MpvPipController] PiP skip by \(seconds)s")
|
||||
guard let delegate = controller?.delegate else {
|
||||
guard let delegate = controller.delegate else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user