fix(native): bound cross-platform lifecycle ownership

This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent 09656fa4d3
commit 9f2e050797
98 changed files with 12908 additions and 1599 deletions
+305 -59
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+31 -2
View File
@@ -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
+586
View File
@@ -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,