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
|
||||
}
|
||||
|
||||
@@ -257,8 +257,8 @@ class MpvPlayerCore: MpvPlayerCoreBase {
|
||||
guard let window = containerView?.window ?? self.window else { return false }
|
||||
let displayManager = window.avDisplayManager
|
||||
|
||||
if width <= 0 || height <= 0 {
|
||||
clearDisplayCriteria(displayManager, reason: "no video dimensions")
|
||||
if !self.validateSideDataDimensions(width: Int64(width), height: Int64(height)) {
|
||||
clearDisplayCriteria(displayManager, reason: "invalid video dimensions")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,13 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
|
||||
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
||||
eventChannel.setStreamHandler(instance)
|
||||
pipChannel.setMethodCallHandler(instance.handlePipCall)
|
||||
pipChannel.setMethodCallHandler { [weak instance] call, result in
|
||||
guard let instance else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
instance.handlePipCall(call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler
|
||||
@@ -210,6 +216,13 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
])
|
||||
return
|
||||
}
|
||||
if manual && isManualPipRequest {
|
||||
result?([
|
||||
"success": false, "errorCode": "failed",
|
||||
"errorMessage": "A PiP start request is already pending",
|
||||
])
|
||||
return
|
||||
}
|
||||
guard let pip = preparePip() else {
|
||||
result?([
|
||||
"success": false, "errorCode": "pip_prepare_failed",
|
||||
@@ -220,10 +233,18 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
|
||||
isManualPipRequest = manual
|
||||
pip.startPip(waitForFrame: manual) { [weak self] started in
|
||||
guard let self else {
|
||||
result?([
|
||||
"success": false, "errorCode": "failed", "errorMessage": "Player disposed",
|
||||
])
|
||||
return
|
||||
}
|
||||
if started {
|
||||
result?(["success": true])
|
||||
} else {
|
||||
self?.cleanupPip(notify: false)
|
||||
if self.playerCore?.isPipStarting == true {
|
||||
self.cleanupPip(notify: false)
|
||||
}
|
||||
result?([
|
||||
"success": false, "errorCode": "failed", "errorMessage": "PiP failed to start",
|
||||
])
|
||||
@@ -310,6 +331,14 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
return
|
||||
}
|
||||
|
||||
// A partially torn-down core must not survive a rapid route replacement.
|
||||
self.pipController?.teardown()
|
||||
self.pipController = nil
|
||||
self.pendingInlineRestoreAfterPip = false
|
||||
self.stopPipTimebaseSync()
|
||||
self.playerCore?.dispose()
|
||||
self.playerCore = nil
|
||||
|
||||
let core = MpvPlayerCore()
|
||||
core.delegate = self
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import AVFoundation
|
||||
import Libmpv
|
||||
import UIKit
|
||||
import Flutter
|
||||
import XCTest
|
||||
|
||||
@@ -47,6 +50,97 @@ final class RecordingMpvPlugin: MpvPluginShared {
|
||||
}
|
||||
}
|
||||
|
||||
final class RecordingLifecycleDelegate: MpvPlayerDelegate {
|
||||
private(set) var events: [String] = []
|
||||
private(set) var properties: [String] = []
|
||||
|
||||
func onPropertyChange(name: String, value: Any?) {
|
||||
properties.append(name)
|
||||
}
|
||||
|
||||
func onEvent(name: String, data: [String: Any]?) {
|
||||
events.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
final class FakePictureInPictureController: MpvPictureInPictureControlling {
|
||||
var isPictureInPicturePossible = false
|
||||
private(set) var startCount = 0
|
||||
private(set) var stopCount = 0
|
||||
private(set) var automaticStartValues: [Bool] = []
|
||||
private(set) var invalidateCount = 0
|
||||
|
||||
func startPictureInPicture() { startCount += 1 }
|
||||
func stopPictureInPicture() { stopCount += 1 }
|
||||
func setAutomaticStart(_ enabled: Bool) { automaticStartValues.append(enabled) }
|
||||
func invalidatePlaybackState() { invalidateCount += 1 }
|
||||
}
|
||||
|
||||
final class RecordingPipDelegate: MpvPipDelegate {
|
||||
private(set) var events: [String] = []
|
||||
var onDidStart: (() -> Void)?
|
||||
func pipWillStart() { events.append("willStart") }
|
||||
func pipDidStart() {
|
||||
onDidStart?()
|
||||
events.append("didStart")
|
||||
}
|
||||
func pipDidStop(restored: Bool) { events.append("didStop:\(restored)") }
|
||||
func pipDidFailToStart(error: Error?) { events.append("failed") }
|
||||
func pipSetPlaying(_ playing: Bool) {}
|
||||
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) { completion() }
|
||||
var isPipPlaying: Bool { true }
|
||||
var pipDuration: Double { 60 }
|
||||
}
|
||||
|
||||
final class ReleaseTrackingCore: MpvPlayerCoreBase {
|
||||
let onDeinit: () -> Void
|
||||
init(onDeinit: @escaping () -> Void) {
|
||||
self.onDeinit = onDeinit
|
||||
super.init()
|
||||
}
|
||||
deinit { onDeinit() }
|
||||
}
|
||||
|
||||
final class ProbeURLProtocol: URLProtocol {
|
||||
private static let lock = NSLock()
|
||||
private static var startHandler: ((ProbeURLProtocol) -> Void)?
|
||||
private static var stopHandler: (() -> Void)?
|
||||
|
||||
static func configure(
|
||||
start: @escaping (ProbeURLProtocol) -> Void,
|
||||
stop: (() -> Void)? = nil
|
||||
) {
|
||||
lock.lock()
|
||||
startHandler = start
|
||||
stopHandler = stop
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
lock.lock()
|
||||
startHandler = nil
|
||||
stopHandler = nil
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
Self.lock.lock()
|
||||
let handler = Self.startHandler
|
||||
Self.lock.unlock()
|
||||
handler?(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {
|
||||
Self.lock.lock()
|
||||
let handler = Self.stopHandler
|
||||
Self.lock.unlock()
|
||||
handler?()
|
||||
}
|
||||
}
|
||||
|
||||
final class MpvPlayerContractTests: XCTestCase {
|
||||
private let failure = NSError(
|
||||
domain: "MpvPlayerContractTests",
|
||||
@@ -108,6 +202,103 @@ final class MpvPlayerContractTests: XCTestCase {
|
||||
XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
|
||||
}
|
||||
|
||||
func testPauseIntentUpdatesCacheBeforeAsyncWriteCompletes() {
|
||||
let core = MpvAudioPlayerCore()
|
||||
XCTAssertTrue(core.initialize())
|
||||
defer {
|
||||
core.dispose()
|
||||
core.queue.sync {}
|
||||
}
|
||||
|
||||
let queueEntered = expectation(description: "mpv queue blocked")
|
||||
let releaseQueue = DispatchSemaphore(value: 0)
|
||||
core.queue.async {
|
||||
queueEntered.fulfill()
|
||||
releaseQueue.wait()
|
||||
}
|
||||
wait(for: [queueEntered], timeout: 2)
|
||||
|
||||
let completion = expectation(description: "pause write completed")
|
||||
core.setPropertyAsync("pause", value: "no") { result in
|
||||
if case .failure(let error) = result {
|
||||
XCTFail("Pause write failed: \(error)")
|
||||
}
|
||||
completion.fulfill()
|
||||
}
|
||||
|
||||
XCTAssertFalse(core.isPaused, "The public pause intent must be visible before the native write completes")
|
||||
releaseQueue.signal()
|
||||
wait(for: [completion], timeout: 2)
|
||||
}
|
||||
|
||||
func testOlderPauseReplyCannotOverwriteNewerUserIntent() {
|
||||
let core = ControllablePropertyCore()
|
||||
let olderResume = core.beginCachedPauseIntent(false)
|
||||
let newerPause = core.beginCachedPauseIntent(true)
|
||||
XCTAssertTrue(core.isPaused)
|
||||
|
||||
core.finishCachedPauseIntent(olderResume, result: .success(()))
|
||||
XCTAssertTrue(
|
||||
core.isPaused,
|
||||
"An older resume reply must not overwrite a newer pending pause intent"
|
||||
)
|
||||
|
||||
core.finishCachedPauseIntent(newerPause, result: .success(()))
|
||||
XCTAssertTrue(core.isPaused)
|
||||
}
|
||||
|
||||
func testPauseObservationAndUserIntentResolveInEventOrder() {
|
||||
let core = ControllablePropertyCore()
|
||||
let olderResume = core.beginCachedPauseIntent(false)
|
||||
|
||||
core.observeCachedPauseForTesting(true)
|
||||
core.finishCachedPauseIntent(olderResume, result: .success(()))
|
||||
XCTAssertTrue(
|
||||
core.isPaused,
|
||||
"A native pause observation must invalidate the older resume write's delayed reply"
|
||||
)
|
||||
|
||||
let newerResume = core.beginCachedPauseIntent(false)
|
||||
core.finishCachedPauseIntent(newerResume, result: .success(()))
|
||||
XCTAssertFalse(
|
||||
core.isPaused,
|
||||
"A user intent created after the native observation must remain authoritative"
|
||||
)
|
||||
}
|
||||
|
||||
func testPauseObservationRetiresOutOfOrderIntentsForSuccessAndFailure() {
|
||||
let newerResults: [Result<Void, Error>] = [
|
||||
.success(()),
|
||||
.failure(failure),
|
||||
]
|
||||
|
||||
for newerResult in newerResults {
|
||||
let core = ControllablePropertyCore()
|
||||
let generationOneResume = core.beginCachedPauseIntent(false)
|
||||
let generationTwoPause = core.beginCachedPauseIntent(true)
|
||||
|
||||
core.observeCachedPauseForTesting(true)
|
||||
core.finishCachedPauseIntent(generationTwoPause, result: newerResult)
|
||||
XCTAssertTrue(
|
||||
core.isPaused,
|
||||
"The observed native pause must survive the newer pending pause's completion"
|
||||
)
|
||||
|
||||
core.finishCachedPauseIntent(generationOneResume, result: .success(()))
|
||||
XCTAssertTrue(
|
||||
core.isPaused,
|
||||
"A late older resume must be inert after a newer intent resolves"
|
||||
)
|
||||
|
||||
let postObservationResume = core.beginCachedPauseIntent(false)
|
||||
core.finishCachedPauseIntent(postObservationResume, result: .success(()))
|
||||
XCTAssertFalse(
|
||||
core.isPaused,
|
||||
"A resume created after the native observation must remain authoritative"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
|
||||
let core = MpvAudioPlayerCore()
|
||||
XCTAssertTrue(core.initialize())
|
||||
@@ -152,6 +343,401 @@ final class MpvPlayerContractTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testQueuedDelegateDeliveryIsDroppedAfterTerminalTransition() {
|
||||
let core = MpvPlayerCoreBase()
|
||||
let delegate = RecordingLifecycleDelegate()
|
||||
core.delegate = delegate
|
||||
core.dispatchDelegateEvent(name: "file-loaded", data: nil)
|
||||
core.dispatchDelegateProperty(name: "time-pos", value: 1.0)
|
||||
XCTAssertTrue(core.beginDisposal())
|
||||
|
||||
let drained = expectation(description: "main delivery drained")
|
||||
DispatchQueue.main.async { drained.fulfill() }
|
||||
wait(for: [drained], timeout: 2)
|
||||
XCTAssertTrue(delegate.events.isEmpty)
|
||||
XCTAssertTrue(delegate.properties.isEmpty)
|
||||
}
|
||||
|
||||
func testWakeupContextDoesNotRetainCallbackTarget() {
|
||||
let released = expectation(description: "callback target released")
|
||||
var core: ReleaseTrackingCore? = ReleaseTrackingCore { released.fulfill() }
|
||||
weak var weakCore = core
|
||||
let context = MpvWakeupCallbackContext(core: core!)
|
||||
|
||||
core = nil
|
||||
wait(for: [released], timeout: 2)
|
||||
XCTAssertNil(weakCore)
|
||||
context.dispatchWakeup()
|
||||
context.detach()
|
||||
}
|
||||
|
||||
func testUnavailablePropertyCompletionRunsExactlyOnceOnMainThread() {
|
||||
let core = MpvAudioPlayerCore()
|
||||
XCTAssertTrue(core.initialize())
|
||||
core.dispose()
|
||||
core.queue.sync {}
|
||||
|
||||
let completed = expectation(description: "unavailable property completed")
|
||||
completed.assertForOverFulfill = true
|
||||
var completionCount = 0
|
||||
DispatchQueue.global().async {
|
||||
core.getPropertyAsync("volume") { result in
|
||||
XCTAssertTrue(Thread.isMainThread)
|
||||
if case .success = result { XCTFail("Expected unavailable property failure") }
|
||||
completionCount += 1
|
||||
completed.fulfill()
|
||||
}
|
||||
}
|
||||
wait(for: [completed], timeout: 2)
|
||||
XCTAssertEqual(completionCount, 1)
|
||||
}
|
||||
|
||||
func testNormalizedPlaybackDelayStringsPassThroughUnchanged() {
|
||||
let core = ControllablePropertyCore()
|
||||
let plugin = RecordingMpvPlugin(core: core)
|
||||
let values = ["0.25", "-0.5", "0", "0.25"]
|
||||
|
||||
for value in values {
|
||||
core.nextResult = .success(())
|
||||
let result = invokeSetProperty(plugin, name: "audio-delay", value: value)
|
||||
XCTAssertEqual(result.count, 1)
|
||||
XCTAssertNil(result[0])
|
||||
}
|
||||
XCTAssertEqual(core.propertyCalls.map(\.1), values)
|
||||
}
|
||||
|
||||
func testNodeConversionBoundsAndDiscardsMalformedSiblings() {
|
||||
let core = MpvPlayerCoreBase()
|
||||
var valid = mpv_node()
|
||||
valid.format = MPV_FORMAT_INT64
|
||||
valid.u.int64 = 7
|
||||
var malformed = mpv_node()
|
||||
malformed.format = MPV_FORMAT_NONE
|
||||
var values = [valid, malformed, valid]
|
||||
var decoded: Any?
|
||||
|
||||
let valueCount = values.count
|
||||
values.withUnsafeMutableBufferPointer { valuesPointer in
|
||||
var list = mpv_node_list()
|
||||
list.num = Int32(valueCount)
|
||||
list.values = valuesPointer.baseAddress
|
||||
withUnsafeMutablePointer(to: &list) { listPointer in
|
||||
var root = mpv_node()
|
||||
root.format = MPV_FORMAT_NODE_ARRAY
|
||||
root.u.list = listPointer
|
||||
decoded = core.convertNode(root)
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(decoded as? [Int64], [7, 7])
|
||||
|
||||
var oversizedBytes = mpv_byte_array()
|
||||
oversizedBytes.size = 16 * 1_024 * 1_024 + 1
|
||||
withUnsafeMutablePointer(to: &oversizedBytes) { bytePointer in
|
||||
var root = mpv_node()
|
||||
root.format = MPV_FORMAT_BYTE_ARRAY
|
||||
root.u.ba = bytePointer
|
||||
XCTAssertNil(core.convertNode(root))
|
||||
}
|
||||
|
||||
var invalidList = mpv_node_list()
|
||||
invalidList.num = -1
|
||||
withUnsafeMutablePointer(to: &invalidList) { listPointer in
|
||||
var root = mpv_node()
|
||||
root.format = MPV_FORMAT_NODE_ARRAY
|
||||
root.u.list = listPointer
|
||||
XCTAssertNil(core.convertNode(root))
|
||||
}
|
||||
XCTAssertTrue(core.validateSideDataDimensions(width: 3_840, height: 2_160))
|
||||
XCTAssertFalse(core.validateSideDataDimensions(width: 0, height: 2_160))
|
||||
XCTAssertFalse(core.validateSideDataDimensions(width: 65_536, height: 2_160))
|
||||
XCTAssertFalse(core.validateSideDataDimensions(width: 16_384, height: 16_384))
|
||||
}
|
||||
|
||||
func testRawEc3LoaderBoundsAndIgnoresLateCallbacksForBothModes() {
|
||||
for finiteLength in [false, true] {
|
||||
let loader = RawEc3Loader(
|
||||
source: URL(string: "https://example.invalid/test.ec3")!,
|
||||
finiteLength: finiteLength,
|
||||
maximumBufferedBytes: 8,
|
||||
sessionConfiguration: .ephemeral
|
||||
)
|
||||
let session = URLSession(configuration: .ephemeral)
|
||||
let task = session.dataTask(with: URL(string: "https://example.invalid/test.ec3")!)
|
||||
|
||||
loader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
|
||||
var snapshot = loader.statusSnapshot()
|
||||
XCTAssertEqual(snapshot.bytesReceived, 4)
|
||||
XCTAssertEqual(snapshot.retainedBytes, 4)
|
||||
XCTAssertNil(snapshot.errorCode)
|
||||
|
||||
loader.urlSession(session, dataTask: task, didReceive: Data([5, 6, 7, 8, 9]))
|
||||
snapshot = loader.statusSnapshot()
|
||||
XCTAssertEqual(snapshot.bytesReceived, 4)
|
||||
XCTAssertEqual(snapshot.retainedBytes, 0)
|
||||
XCTAssertEqual(snapshot.errorCode, "response_too_large")
|
||||
|
||||
loader.urlSession(session, dataTask: task, didReceive: Data([10]))
|
||||
let lateSnapshot = loader.statusSnapshot()
|
||||
XCTAssertEqual(lateSnapshot.bytesReceived, snapshot.bytesReceived)
|
||||
XCTAssertEqual(lateSnapshot.retainedBytes, 0)
|
||||
loader.cancel()
|
||||
loader.cancel()
|
||||
XCTAssertEqual(loader.statusSnapshot().pendingRequestCount, 0)
|
||||
let cancelledLoader = RawEc3Loader(
|
||||
source: URL(string: "https://example.invalid/cancel.ec3")!,
|
||||
finiteLength: finiteLength,
|
||||
maximumBufferedBytes: 8,
|
||||
sessionConfiguration: .ephemeral
|
||||
)
|
||||
cancelledLoader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
|
||||
XCTAssertEqual(cancelledLoader.statusSnapshot().retainedBytes, 4)
|
||||
cancelledLoader.cancel()
|
||||
cancelledLoader.cancel()
|
||||
let cancelledSnapshot = cancelledLoader.statusSnapshot()
|
||||
XCTAssertEqual(cancelledSnapshot.retainedBytes, 0)
|
||||
XCTAssertEqual(cancelledSnapshot.pendingRequestCount, 0)
|
||||
session.invalidateAndCancel()
|
||||
}
|
||||
}
|
||||
|
||||
func testRawEc3LoaderCompletesThroughInjectedURLProtocolForBothModes() {
|
||||
defer { ProbeURLProtocol.reset() }
|
||||
for finiteLength in [false, true] {
|
||||
let requestStarted = expectation(description: "probe request started")
|
||||
let loaderFinished = expectation(description: "probe loader finished")
|
||||
ProbeURLProtocol.configure { protocolInstance in
|
||||
let response = URLResponse(
|
||||
url: protocolInstance.request.url!,
|
||||
mimeType: "audio/eac3",
|
||||
expectedContentLength: -1,
|
||||
textEncodingName: nil
|
||||
)
|
||||
protocolInstance.client?.urlProtocol(
|
||||
protocolInstance,
|
||||
didReceive: response,
|
||||
cacheStoragePolicy: .notAllowed
|
||||
)
|
||||
protocolInstance.client?.urlProtocol(protocolInstance, didLoad: Data([1, 2, 3, 4]))
|
||||
protocolInstance.client?.urlProtocolDidFinishLoading(protocolInstance)
|
||||
requestStarted.fulfill()
|
||||
}
|
||||
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [ProbeURLProtocol.self]
|
||||
let loader = RawEc3Loader(
|
||||
source: URL(string: "https://probe.test/audio.ec3")!,
|
||||
finiteLength: finiteLength,
|
||||
maximumBufferedBytes: 8,
|
||||
sessionConfiguration: configuration,
|
||||
terminalHandlerForTesting: { loaderFinished.fulfill() }
|
||||
)
|
||||
loader.begin()
|
||||
wait(for: [requestStarted, loaderFinished], timeout: 2)
|
||||
|
||||
let snapshot = loader.statusSnapshot()
|
||||
XCTAssertTrue(snapshot.isFinished)
|
||||
XCTAssertEqual(snapshot.bytesReceived, 4)
|
||||
XCTAssertEqual(snapshot.retainedBytes, 4)
|
||||
XCTAssertNil(snapshot.errorCode)
|
||||
|
||||
loader.cancel()
|
||||
let cancelled = loader.statusSnapshot()
|
||||
XCTAssertEqual(cancelled.retainedBytes, 0)
|
||||
XCTAssertEqual(cancelled.pendingRequestCount, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func testPipStartWaitsForDelegateAndCompletesOnce() {
|
||||
let fake = FakePictureInPictureController()
|
||||
fake.isPictureInPicturePossible = true
|
||||
let controller = MpvPipController(
|
||||
controller: fake,
|
||||
readiness: { (true, true, true) },
|
||||
retryScheduler: { $0() }
|
||||
)
|
||||
let delegate = RecordingPipDelegate()
|
||||
controller.delegate = delegate
|
||||
var results: [Bool] = []
|
||||
|
||||
delegate.onDidStart = {
|
||||
XCTAssertEqual(results, [true], "Manual result must resolve before delegate suspension")
|
||||
}
|
||||
controller.startPip { results.append($0) }
|
||||
XCTAssertEqual(fake.startCount, 1)
|
||||
XCTAssertTrue(results.isEmpty)
|
||||
controller.pictureInPictureWillStart()
|
||||
controller.pictureInPictureDidStart()
|
||||
XCTAssertEqual(Array(delegate.events.prefix(2)), ["willStart", "didStart"])
|
||||
XCTAssertEqual(results, [true])
|
||||
|
||||
controller.pictureInPictureDidStart()
|
||||
controller.teardown()
|
||||
XCTAssertEqual(results, [true])
|
||||
}
|
||||
|
||||
func testRepeatedAutoStartDuringCurrentControllerStartDoesNotRejectDidStart() {
|
||||
let fake = FakePictureInPictureController()
|
||||
let controller = MpvPipController(
|
||||
controller: fake,
|
||||
readiness: { (true, true, true) },
|
||||
retryScheduler: { $0() }
|
||||
)
|
||||
let delegate = RecordingPipDelegate()
|
||||
controller.delegate = delegate
|
||||
|
||||
controller.setAutoStart(true)
|
||||
controller.pictureInPictureWillStart(from: fake)
|
||||
controller.setAutoStart(true)
|
||||
controller.pictureInPictureDidStart(from: fake)
|
||||
|
||||
XCTAssertEqual(fake.automaticStartValues, [true, true])
|
||||
XCTAssertEqual(delegate.events, ["willStart", "didStart"])
|
||||
XCTAssertEqual(
|
||||
fake.stopCount,
|
||||
0,
|
||||
"Reasserting an enabled auto-start setting must not reject the in-flight system start"
|
||||
)
|
||||
}
|
||||
|
||||
func testPipStartTimesOutWithoutDelegateOutcome() {
|
||||
let fake = FakePictureInPictureController()
|
||||
var timeouts: [() -> Void] = []
|
||||
let controller = MpvPipController(
|
||||
controller: fake,
|
||||
readiness: { (true, true, true) },
|
||||
retryScheduler: { $0() },
|
||||
startTimeoutScheduler: { timeouts.append($0) }
|
||||
)
|
||||
var results: [Bool] = []
|
||||
|
||||
controller.startPip { results.append($0) }
|
||||
XCTAssertEqual(fake.startCount, 1)
|
||||
XCTAssertTrue(results.isEmpty)
|
||||
XCTAssertEqual(timeouts.count, 1)
|
||||
|
||||
timeouts[0]()
|
||||
XCTAssertEqual(results, [false])
|
||||
XCTAssertEqual(fake.stopCount, 1)
|
||||
|
||||
controller.pictureInPictureDidStart()
|
||||
controller.pictureInPictureFailedToStart(error: NSError(domain: "late", code: 1))
|
||||
XCTAssertEqual(results, [false])
|
||||
}
|
||||
|
||||
func testPipTimeoutRecreatesControllerAndRejectsRetiredCallbacks() {
|
||||
let displayLayer = AVSampleBufferDisplayLayer()
|
||||
let retired = FakePictureInPictureController()
|
||||
let replacement = FakePictureInPictureController()
|
||||
retired.isPictureInPicturePossible = true
|
||||
replacement.isPictureInPicturePossible = true
|
||||
var timeouts: [() -> Void] = []
|
||||
var replacementLayers: [AVSampleBufferDisplayLayer?] = []
|
||||
let controller = MpvPipController(
|
||||
controller: retired,
|
||||
sampleBufferDisplayLayer: displayLayer,
|
||||
readiness: { (true, true, true) },
|
||||
retryScheduler: { $0() },
|
||||
startTimeoutScheduler: { timeouts.append($0) },
|
||||
replacementControllerFactory: { layer in
|
||||
replacementLayers.append(layer)
|
||||
return replacement
|
||||
}
|
||||
)
|
||||
let delegate = RecordingPipDelegate()
|
||||
controller.delegate = delegate
|
||||
controller.setAutoStart(true)
|
||||
var results: [Bool] = []
|
||||
|
||||
controller.startPip { results.append($0) }
|
||||
XCTAssertEqual(retired.startCount, 1)
|
||||
XCTAssertEqual(timeouts.count, 1)
|
||||
|
||||
timeouts[0]()
|
||||
XCTAssertEqual(results, [false])
|
||||
XCTAssertEqual(retired.stopCount, 1)
|
||||
XCTAssertEqual(retired.automaticStartValues, [true, false])
|
||||
XCTAssertEqual(replacementLayers.count, 1)
|
||||
XCTAssertTrue(replacementLayers[0] === displayLayer)
|
||||
XCTAssertEqual(replacement.automaticStartValues, [true])
|
||||
|
||||
controller.startPip { results.append($0) }
|
||||
XCTAssertEqual(replacement.startCount, 1)
|
||||
XCTAssertEqual(timeouts.count, 2)
|
||||
|
||||
controller.pictureInPictureWillStart(from: retired)
|
||||
controller.pictureInPictureDidStart(from: retired)
|
||||
controller.pictureInPictureFailedToStart(
|
||||
from: retired,
|
||||
error: NSError(domain: "late-retired-controller", code: 1)
|
||||
)
|
||||
controller.pictureInPictureDidStop(from: retired)
|
||||
XCTAssertEqual(results, [false])
|
||||
XCTAssertTrue(delegate.events.isEmpty)
|
||||
XCTAssertEqual(retired.stopCount, 1)
|
||||
XCTAssertEqual(
|
||||
replacement.startCount,
|
||||
1,
|
||||
"A retired controller callback must not disturb the replacement's pending start"
|
||||
)
|
||||
|
||||
controller.pictureInPictureWillStart(from: replacement)
|
||||
controller.pictureInPictureDidStart(from: replacement)
|
||||
XCTAssertEqual(results, [false, true])
|
||||
XCTAssertEqual(delegate.events, ["willStart", "didStart"])
|
||||
XCTAssertEqual(replacementLayers.count, 1)
|
||||
}
|
||||
|
||||
func testPipTeardownCancelsRetryAndLateWork() {
|
||||
let fake = FakePictureInPictureController()
|
||||
var possible = false
|
||||
var retries: [() -> Void] = []
|
||||
let controller = MpvPipController(
|
||||
controller: fake,
|
||||
readiness: { (possible, true, true) },
|
||||
retryScheduler: { retries.append($0) }
|
||||
)
|
||||
var results: [Bool] = []
|
||||
|
||||
controller.startPip { results.append($0) }
|
||||
XCTAssertEqual(retries.count, 1)
|
||||
controller.teardown()
|
||||
XCTAssertEqual(results, [false])
|
||||
possible = true
|
||||
retries.forEach { $0() }
|
||||
XCTAssertEqual(fake.startCount, 0)
|
||||
XCTAssertEqual(results, [false])
|
||||
}
|
||||
|
||||
func testPipFailureAndLateRestoreRemainSingleShot() {
|
||||
let fake = FakePictureInPictureController()
|
||||
let controller = MpvPipController(
|
||||
controller: fake,
|
||||
readiness: { (true, true, true) },
|
||||
retryScheduler: { $0() }
|
||||
)
|
||||
let delegate = RecordingPipDelegate()
|
||||
controller.delegate = delegate
|
||||
var startResults: [Bool] = []
|
||||
|
||||
controller.startPip { startResults.append($0) }
|
||||
controller.pictureInPictureWillStart()
|
||||
controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 1))
|
||||
controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 2))
|
||||
XCTAssertEqual(startResults, [false])
|
||||
XCTAssertEqual(delegate.events.filter { $0 == "failed" }.count, 1)
|
||||
|
||||
controller.startPip { startResults.append($0) }
|
||||
controller.pictureInPictureWillStart()
|
||||
controller.pictureInPictureDidStart()
|
||||
XCTAssertEqual(startResults, [false, true])
|
||||
|
||||
var restoreResults: [Bool] = []
|
||||
controller.restoreUserInterface { restoreResults.append($0) }
|
||||
controller.teardown()
|
||||
controller.restoreUserInterface { restoreResults.append($0) }
|
||||
XCTAssertEqual(restoreResults, [true, false])
|
||||
}
|
||||
|
||||
private func invokeSetProperty(
|
||||
_ plugin: RecordingMpvPlugin,
|
||||
name: String,
|
||||
|
||||
Reference in New Issue
Block a user