1077 lines
42 KiB
Swift
1077 lines
42 KiB
Swift
// Copyright 2013 The Flutter Authors
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
import AVFoundation
|
|
import Testing
|
|
@preconcurrency @testable import video_player_avfoundation
|
|
import video_player_avfoundation_objc
|
|
|
|
#if os(iOS)
|
|
import Flutter
|
|
#else
|
|
import FlutterMacOS
|
|
#endif
|
|
|
|
private let mp4TestURI =
|
|
"https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4"
|
|
private let hlsTestURI =
|
|
"https://flutter.github.io/assets-for-api-docs/assets/videos/hls/bee.m3u8"
|
|
private let mp3AudioTestURI =
|
|
"https://flutter.github.io/assets-for-api-docs/assets/audio/rooster.mp3"
|
|
private let hlsAudioTestURI =
|
|
"https://flutter.github.io/assets-for-api-docs/assets/videos/hls/bee_audio_only.m3u8"
|
|
|
|
@MainActor struct VideoPlayerTests {
|
|
|
|
@Test func blankVideoBugWithEncryptedVideoStreamAndInvertedAspectRatioBugForSomeVideoStream()
|
|
throws
|
|
{
|
|
// This is to fix 2 bugs: 1. blank video for encrypted video streams on iOS 16
|
|
// (https://github.com/flutter/flutter/issues/111457) and 2. swapped width and height for some
|
|
// video streams (not just iOS 16). (https://github.com/flutter/flutter/issues/109116). An
|
|
// invisible AVPlayerLayer is used to overwrite the protection of pixel buffers in those streams
|
|
// for issue #1, and restore the correct width and height for issue #2.
|
|
#if os(iOS)
|
|
let view = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 10))
|
|
let viewController = UIViewController()
|
|
viewController.view = view
|
|
let viewProvider = StubViewProvider(viewController: viewController)
|
|
#else
|
|
let view = NSView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
|
|
view.wantsLayer = true
|
|
let viewProvider = StubViewProvider(view: view)
|
|
#endif
|
|
let videoPlayerPlugin = try createInitializedPlugin(viewProvider: viewProvider)
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
|
|
let player =
|
|
videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer
|
|
|
|
#expect(player.playerLayer.superlayer == view.layer)
|
|
}
|
|
|
|
@Test func playerForPlatformViewDoesNotRegisterTexture() throws {
|
|
let textureRegistry = TestTextureRegistry()
|
|
let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
|
|
let videoPlayerPlugin = try createInitializedPlugin(
|
|
displayLinkFactory: stubDisplayLinkFactory,
|
|
textureRegistry: textureRegistry)
|
|
|
|
_ = try videoPlayerPlugin.createPlatformViewPlayer(
|
|
options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
|
|
|
|
#expect(!textureRegistry.registeredTexture)
|
|
}
|
|
|
|
@Test func seekToWhilePausedStartsDisplayLinkTemporarily() async throws {
|
|
let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
|
|
let mockVideoOutput = TestPixelBufferSource()
|
|
// Display link and frame updater wire-up is currently done in FVPVideoPlayerPlugin, so create
|
|
// the player via the plugin instead of directly to include that logic in the test.
|
|
let videoPlayerPlugin = try createInitializedPlugin(
|
|
avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
|
|
displayLinkFactory: stubDisplayLinkFactory)
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
|
|
let player =
|
|
videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer
|
|
|
|
// Ensure that the video playback is paused before seeking.
|
|
var error: FlutterError?
|
|
player.pauseWithError(&error)
|
|
#expect(error == nil)
|
|
|
|
await asyncSeekTo(player: player, time: 1234)
|
|
|
|
// Seeking to a new position should start the display link temporarily.
|
|
#expect(stubDisplayLinkFactory.displayLink.running)
|
|
|
|
// Simulate a buffer being available.
|
|
var bufferRef: CVPixelBuffer?
|
|
CVPixelBufferCreate(nil, 1, 1, kCVPixelFormatType_32BGRA, nil, &bufferRef)
|
|
mockVideoOutput.pixelBuffer = bufferRef
|
|
// Simulate a callback from the engine to request a new frame.
|
|
stubDisplayLinkFactory.fireDisplayLink?()
|
|
player.copyPixelBuffer()
|
|
// Since a frame was found, and the video is paused, the display link should be paused again.
|
|
#expect(!stubDisplayLinkFactory.displayLink.running)
|
|
}
|
|
|
|
@Test func initStartsDisplayLinkTemporarily() throws {
|
|
let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
|
|
let mockVideoOutput = TestPixelBufferSource()
|
|
let videoPlayerPlugin = try createInitializedPlugin(
|
|
avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
|
|
displayLinkFactory: stubDisplayLinkFactory)
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
|
|
|
|
// Init should start the display link temporarily.
|
|
#expect(stubDisplayLinkFactory.displayLink.running)
|
|
|
|
// Simulate a buffer being available.
|
|
var bufferRef: CVPixelBuffer?
|
|
CVPixelBufferCreate(nil, 1, 1, kCVPixelFormatType_32BGRA, nil, &bufferRef)
|
|
mockVideoOutput.pixelBuffer = bufferRef
|
|
// Simulate a callback from the engine to request a new frame.
|
|
let player =
|
|
videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer
|
|
stubDisplayLinkFactory.fireDisplayLink?()
|
|
player.copyPixelBuffer()
|
|
// Since a frame was found, and the video is paused, the display link should be paused again.
|
|
#expect(!stubDisplayLinkFactory.displayLink.running)
|
|
}
|
|
|
|
@Test func seekToWhilePlayingDoesNotStopDisplayLink() async throws {
|
|
let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
|
|
let mockVideoOutput = TestPixelBufferSource()
|
|
let videoPlayerPlugin = try createInitializedPlugin(
|
|
avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
|
|
displayLinkFactory: stubDisplayLinkFactory)
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
|
|
let player =
|
|
videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer
|
|
|
|
// Ensure that the video is playing before seeking.
|
|
var error: FlutterError?
|
|
player.playWithError(&error)
|
|
#expect(error == nil)
|
|
|
|
await asyncSeekTo(player: player, time: 1234)
|
|
|
|
#expect(stubDisplayLinkFactory.displayLink.running)
|
|
|
|
// Simulate a buffer being available.
|
|
var bufferRef: CVPixelBuffer?
|
|
CVPixelBufferCreate(nil, 1, 1, kCVPixelFormatType_32BGRA, nil, &bufferRef)
|
|
mockVideoOutput.pixelBuffer = bufferRef
|
|
// Simulate a callback from the engine to request a new frame.
|
|
stubDisplayLinkFactory.fireDisplayLink?()
|
|
// Since the video was playing, the display link should not be paused after getting a buffer.
|
|
#expect(stubDisplayLinkFactory.displayLink.running)
|
|
}
|
|
|
|
@Test func pauseWhileWaitingForFrameDoesNotStopDisplayLink() throws {
|
|
let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
|
|
// Display link and frame updater wire-up is currently done in FVPVideoPlayerPlugin, so create
|
|
// the player via the plugin instead of directly to include that logic in the test.
|
|
let videoPlayerPlugin = try createInitializedPlugin(displayLinkFactory: stubDisplayLinkFactory)
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
|
|
let player =
|
|
videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer
|
|
|
|
// Run a play/pause cycle to force the pause codepath to run completely.
|
|
var error: FlutterError?
|
|
player.playWithError(&error)
|
|
#expect(error == nil)
|
|
player.pauseWithError(&error)
|
|
#expect(error == nil)
|
|
|
|
// Since a buffer hasn't been available yet, the pause should not have stopped the display link.
|
|
#expect(stubDisplayLinkFactory.displayLink.running)
|
|
}
|
|
|
|
@Test func disposeWhilePlayingStopsDisplayLink() async throws {
|
|
let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
|
|
let mockVideoOutput = TestPixelBufferSource()
|
|
let videoPlayerPlugin = try createInitializedPlugin(
|
|
avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
|
|
displayLinkFactory: stubDisplayLinkFactory)
|
|
|
|
var error: FlutterError?
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
|
|
let player =
|
|
videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer
|
|
|
|
player.playWithError(&error)
|
|
#expect(error == nil)
|
|
player.disposeWithError(&error)
|
|
#expect(error == nil)
|
|
|
|
#expect(!stubDisplayLinkFactory.displayLink.running)
|
|
}
|
|
|
|
@Test func deregistersFromPlayer() throws {
|
|
let videoPlayerPlugin = try createInitializedPlugin()
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
|
|
let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])
|
|
|
|
var error: FlutterError?
|
|
player.disposeWithError(&error)
|
|
#expect(error == nil)
|
|
#expect(videoPlayerPlugin.playersByIdentifier.count == 0)
|
|
}
|
|
|
|
@Test func bufferingStateFromPlayer() async throws {
|
|
// TODO(stuartmorgan): Rewrite this test to use stubs, instead of running for 10
|
|
// seconds with a real player and hoping to get buffer status updates.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
|
|
let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])
|
|
let avPlayer = player.player
|
|
avPlayer.play()
|
|
|
|
let eventSink: FlutterEventSink = { event in
|
|
guard let event = event as? [String: Any], let eventType = event["event"] as? String else {
|
|
return
|
|
}
|
|
if eventType == "bufferingEnd" {
|
|
#expect(avPlayer.currentItem!.isPlaybackLikelyToKeepUp)
|
|
}
|
|
if eventType == "bufferingStart" {
|
|
#expect(!avPlayer.currentItem!.isPlaybackLikelyToKeepUp)
|
|
}
|
|
}
|
|
(player.eventListener as? FlutterStreamHandler)?.onListen(
|
|
withArguments: nil, eventSink: eventSink)
|
|
|
|
// Load for a while to let some buffer events happen.
|
|
try await Task.sleep(nanoseconds: 10 * 1_000_000_000)
|
|
}
|
|
|
|
private func durationApproximatelyEquals(_ actual: Int64, _ expected: Int64, tolerance: Int64)
|
|
-> Bool
|
|
{
|
|
return abs(actual - expected) < tolerance
|
|
}
|
|
|
|
@Test func videoControls() async throws {
|
|
let eventListener = try await sanityTestURI(mp4TestURI)
|
|
#expect(eventListener.initializationSize.height == 720)
|
|
#expect(eventListener.initializationSize.width == 1280)
|
|
#expect(durationApproximatelyEquals(eventListener.initializationDuration, 4000, tolerance: 200))
|
|
}
|
|
|
|
@Test func audioControls() async throws {
|
|
let eventListener = try await sanityTestURI(mp3AudioTestURI)
|
|
#expect(eventListener.initializationSize.height == 0)
|
|
#expect(eventListener.initializationSize.width == 0)
|
|
#expect(durationApproximatelyEquals(eventListener.initializationDuration, 5400, tolerance: 200))
|
|
}
|
|
|
|
@Test func hLSControls() async throws {
|
|
let eventListener = try await sanityTestURI(hlsTestURI)
|
|
#expect(eventListener.initializationSize.height == 720)
|
|
#expect(eventListener.initializationSize.width == 1280)
|
|
#expect(durationApproximatelyEquals(eventListener.initializationDuration, 4000, tolerance: 200))
|
|
}
|
|
|
|
@Test(.disabled("Flaky"), .bug("https://github.com/flutter/flutter/issues/164381"))
|
|
func audioOnlyHLSControls() async throws {
|
|
let eventListener = try await sanityTestURI(hlsAudioTestURI)
|
|
#expect(eventListener.initializationSize.height == 0)
|
|
#expect(eventListener.initializationSize.width == 0)
|
|
#expect(durationApproximatelyEquals(eventListener.initializationDuration, 4000, tolerance: 200))
|
|
}
|
|
|
|
#if os(iOS)
|
|
@Test func transformFixOrientationUp() {
|
|
let size = CGSize(width: 800, height: 600)
|
|
let naturalTransform = CGAffineTransform.identity
|
|
let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
|
|
#expect(t.tx == 0)
|
|
#expect(t.ty == 0)
|
|
}
|
|
|
|
@Test func transformFixOrientationDown() {
|
|
let size = CGSize(width: 800, height: 600)
|
|
let naturalTransform = CGAffineTransform(a: -1, b: 0, c: 0, d: -1, tx: 0, ty: 0)
|
|
let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
|
|
#expect(t.tx == size.width)
|
|
#expect(t.ty == size.height)
|
|
}
|
|
|
|
@Test func transformFixOrientationLeft() {
|
|
let size = CGSize(width: 800, height: 600)
|
|
let naturalTransform = CGAffineTransform(a: 0, b: -1, c: 1, d: 0, tx: 0, ty: 0)
|
|
let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
|
|
#expect(t.tx == 0)
|
|
#expect(t.ty == size.width)
|
|
}
|
|
|
|
@Test func transformFixOrientationRight() {
|
|
let size = CGSize(width: 800, height: 600)
|
|
let naturalTransform = CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 0, ty: 0)
|
|
let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
|
|
#expect(t.tx == size.height)
|
|
#expect(t.ty == 0)
|
|
}
|
|
|
|
@Test func transformFixOrientationUpMirrored() {
|
|
let size = CGSize(width: 800, height: 600)
|
|
let naturalTransform = CGAffineTransform(a: -1, b: 0, c: 0, d: 1, tx: 0, ty: 0)
|
|
let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
|
|
#expect(t.tx == size.width)
|
|
#expect(t.ty == 0)
|
|
}
|
|
|
|
@Test func transformFixOrientationDownMirrored() {
|
|
let size = CGSize(width: 800, height: 600)
|
|
let naturalTransform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0)
|
|
let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
|
|
#expect(t.tx == 0)
|
|
#expect(t.ty == size.height)
|
|
}
|
|
|
|
@Test func transformFixOrientationLeftMirrored() {
|
|
let size = CGSize(width: 800, height: 600)
|
|
let naturalTransform = CGAffineTransform(a: 0, b: -1, c: -1, d: 0, tx: 0, ty: 0)
|
|
let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
|
|
#expect(t.tx == size.height)
|
|
#expect(t.ty == size.width)
|
|
}
|
|
|
|
@Test func transformFixOrientationRightMirrored() {
|
|
let size = CGSize(width: 800, height: 600)
|
|
let naturalTransform = CGAffineTransform(a: 0, b: 1, c: 1, d: 0, tx: 0, ty: 0)
|
|
let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
|
|
#expect(t.tx == 0)
|
|
#expect(t.ty == 0)
|
|
}
|
|
#endif
|
|
|
|
@Test func seekToleranceWhenNotSeekingToEnd() async {
|
|
let inspectableAVPlayer = InspectableAVPlayer()
|
|
let stubAVFactory = StubFVPAVFactory(player: inspectableAVPlayer)
|
|
let player = FVPVideoPlayer(
|
|
playerItem: StubPlayerItem(),
|
|
avFactory: stubAVFactory,
|
|
viewProvider: StubViewProvider())
|
|
let listener = StubEventListener()
|
|
player.eventListener = listener
|
|
|
|
await asyncSeekTo(player: player, time: 1234)
|
|
|
|
#expect(inspectableAVPlayer.beforeTolerance?.intValue == 0)
|
|
#expect(inspectableAVPlayer.afterTolerance?.intValue == 0)
|
|
}
|
|
|
|
@Test func seekToleranceWhenSeekingToEnd() async {
|
|
let inspectableAVPlayer = InspectableAVPlayer()
|
|
let stubAVFactory = StubFVPAVFactory(player: inspectableAVPlayer)
|
|
let player = FVPVideoPlayer(
|
|
playerItem: StubPlayerItem(),
|
|
avFactory: stubAVFactory,
|
|
viewProvider: StubViewProvider())
|
|
let listener = StubEventListener()
|
|
player.eventListener = listener
|
|
|
|
await asyncSeekTo(player: player, time: 0)
|
|
|
|
#expect((inspectableAVPlayer.beforeTolerance?.intValue ?? 0) > 0)
|
|
#expect((inspectableAVPlayer.afterTolerance?.intValue ?? 0) > 0)
|
|
}
|
|
|
|
@Test func setPreventsDisplaySleepDuringVideoPlayback() {
|
|
let stubAVFactory = StubFVPAVFactory(player: AVPlayer())
|
|
let player = FVPVideoPlayer(
|
|
playerItem: StubPlayerItem(),
|
|
avFactory: stubAVFactory,
|
|
viewProvider: StubViewProvider())
|
|
let listener = StubEventListener()
|
|
player.eventListener = listener
|
|
|
|
var error: FlutterError?
|
|
// The setter should update the underlying AVPlayer in both directions.
|
|
player.setPreventsDisplaySleepDuringVideoPlayback(false, error: &error)
|
|
#expect(error == nil)
|
|
#expect(player.player.preventsDisplaySleepDuringVideoPlayback == false)
|
|
|
|
player.setPreventsDisplaySleepDuringVideoPlayback(true, error: &error)
|
|
#expect(error == nil)
|
|
#expect(player.player.preventsDisplaySleepDuringVideoPlayback == true)
|
|
}
|
|
|
|
/// Sanity checks a video player playing the given URL with the actual AVPlayer. This is essentially
|
|
/// a mini integration test of the player component.
|
|
///
|
|
/// Returns the stub event listener to allow tests to inspect the call state.
|
|
func sanityTestURI(_ testURI: String) async throws -> StubEventListener {
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let testURL = try #require(URL(string: testURI))
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: testURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
let listener = StubEventListener()
|
|
await withCheckedContinuation { continuation in
|
|
listener.onInitialized = { continuation.resume() }
|
|
player.eventListener = listener
|
|
}
|
|
|
|
// Starts paused.
|
|
let avPlayer = player.player
|
|
#expect(avPlayer.rate == 0)
|
|
#expect(avPlayer.volume == 1)
|
|
#expect(avPlayer.timeControlStatus == .paused)
|
|
|
|
// Change playback speed.
|
|
var error: FlutterError?
|
|
player.setPlaybackSpeed(2, error: &error)
|
|
#expect(error == nil)
|
|
player.playWithError(&error)
|
|
#expect(error == nil)
|
|
#expect(avPlayer.rate == 2)
|
|
#expect(avPlayer.timeControlStatus == .waitingToPlayAtSpecifiedRate)
|
|
|
|
// Volume
|
|
player.setVolume(0.1, error: &error)
|
|
#expect(error == nil)
|
|
#expect(avPlayer.volume == 0.1)
|
|
|
|
return listener
|
|
}
|
|
|
|
// Checks whether [AVPlayer rate] KVO observations are correctly detached.
|
|
// - https://github.com/flutter/flutter/issues/124937
|
|
//
|
|
// Failing to de-register results in a crash in [AVPlayer willChangeValueForKey:].
|
|
@Test func doesNotCrashOnRateObservationAfterDisposal() async throws {
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
|
|
var avPlayer: AVPlayer? = nil
|
|
weak var weakPlayer: FVPVideoPlayer? = nil
|
|
|
|
// Autoreleasepool is needed to simulate conditions of FVPVideoPlayer deallocation.
|
|
try autoreleasepool {
|
|
let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)
|
|
|
|
var error: FlutterError?
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
|
|
|
|
let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])
|
|
weakPlayer = player
|
|
avPlayer = player.player
|
|
|
|
player.disposeWithError(&error)
|
|
#expect(error == nil)
|
|
}
|
|
|
|
// Wait for the weak pointer to be invalidated, indicating that the player has been deallocated.
|
|
let checkInterval = 0.1
|
|
let maxTries = Int64(30 / checkInterval)
|
|
for _ in 1...maxTries {
|
|
if weakPlayer == nil {
|
|
break
|
|
}
|
|
try await Task.sleep(nanoseconds: UInt64(checkInterval * 1_000_000_000))
|
|
}
|
|
|
|
await MainActor.run {
|
|
avPlayer?.willChangeValue(forKey: "rate")
|
|
avPlayer?.didChangeValue(forKey: "rate")
|
|
}
|
|
// No assertions needed. Lack of crash is a success.
|
|
}
|
|
|
|
// During the hot reload:
|
|
// 1. `[FVPVideoPlayer onTextureUnregistered:]` gets called.
|
|
// 2. `[FVPVideoPlayerPlugin initialize:]` gets called.
|
|
//
|
|
// Both of these methods dispatch [FVPVideoPlayer dispose] on the main thread
|
|
// leading to a possible crash when de-registering observers twice.
|
|
@Test func hotReloadDoesNotCrash() async throws {
|
|
weak var weakPlayer: FVPVideoPlayer? = nil
|
|
|
|
// Autoreleasepool is needed to simulate conditions of FVPVideoPlayer deallocation.
|
|
try autoreleasepool {
|
|
let videoPlayerPlugin = try createInitializedPlugin(avFactory: StubFVPAVFactory())
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
|
|
|
|
let player =
|
|
videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer
|
|
weakPlayer = player
|
|
|
|
player.onTextureUnregistered(StubTexture())
|
|
|
|
try videoPlayerPlugin.initialize()
|
|
}
|
|
|
|
// Wait for the weak pointer to be invalidated, indicating that the player has been deallocated.
|
|
let checkInterval = 0.1
|
|
let maxTries = Int64(30 / checkInterval)
|
|
for _ in 1...maxTries {
|
|
if weakPlayer == nil {
|
|
break
|
|
}
|
|
try await Task.sleep(nanoseconds: UInt64(checkInterval * 1_000_000_000))
|
|
}
|
|
// No assertions needed. Lack of crash is a success.
|
|
}
|
|
|
|
@Test func failedToLoadVideoEventShouldBeAlwaysSent() async throws {
|
|
// Use real objects to test a real failure flow.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)
|
|
|
|
// Provide a URI that is a valid URI, but not a video, to trigger a failure inside AVPlayer.
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: "https://flutter.dev", httpHeaders: [:]))
|
|
let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])
|
|
|
|
await withCheckedContinuation { continuation in
|
|
// TODO(stuartmorgan): Update this test to instead use a mock listener, and add separate unit
|
|
// tests of FVPEventBridge.
|
|
let eventSink: FlutterEventSink = { event in
|
|
if event is FlutterError {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
(player.eventListener as? FlutterStreamHandler)?.onListen(
|
|
withArguments: nil, eventSink: eventSink)
|
|
}
|
|
}
|
|
|
|
@Test func updatePlayingStateShouldNotResetRate() async throws {
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let testURL = try #require(URL(string: mp4TestURI))
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: testURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
await withCheckedContinuation { continuation in
|
|
let listener = StubEventListener(onInitialized: { continuation.resume() })
|
|
player.eventListener = listener
|
|
}
|
|
|
|
var error: FlutterError?
|
|
player.setPlaybackSpeed(2, error: &error)
|
|
#expect(error == nil)
|
|
player.playWithError(&error)
|
|
#expect(error == nil)
|
|
#expect(player.player.rate == 2)
|
|
}
|
|
|
|
@Test func playerShouldNotDropEverySecondFrame() throws {
|
|
let textureRegistry = TestTextureRegistry()
|
|
let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
|
|
let mockVideoOutput = TestPixelBufferSource()
|
|
let videoPlayerPlugin = try createInitializedPlugin(
|
|
avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
|
|
displayLinkFactory: stubDisplayLinkFactory,
|
|
textureRegistry: textureRegistry)
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
|
|
let playerIdentifier = identifiers.playerId
|
|
let player =
|
|
videoPlayerPlugin.playersByIdentifier[playerIdentifier] as! FVPTextureBasedVideoPlayer
|
|
|
|
func addFrame() {
|
|
var bufferRef: CVPixelBuffer?
|
|
CVPixelBufferCreate(nil, 1, 1, kCVPixelFormatType_32BGRA, nil, &bufferRef)
|
|
mockVideoOutput.pixelBuffer = bufferRef
|
|
}
|
|
|
|
addFrame()
|
|
stubDisplayLinkFactory.fireDisplayLink?()
|
|
player.copyPixelBuffer()
|
|
#expect(textureRegistry.textureFrameAvailableCount == 1)
|
|
|
|
addFrame()
|
|
stubDisplayLinkFactory.fireDisplayLink?()
|
|
player.copyPixelBuffer()
|
|
#expect(textureRegistry.textureFrameAvailableCount == 2)
|
|
}
|
|
|
|
@Test func videoOutputIsAddedWhenAVPlayerIsInitialized() async throws {
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)
|
|
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
|
|
let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])
|
|
|
|
let listener = StubEventListener()
|
|
await withCheckedContinuation { continuation in
|
|
listener.onInitialized = { continuation.resume() }
|
|
player.eventListener = listener
|
|
}
|
|
|
|
let item = try #require(player.player.currentItem)
|
|
// Video output is added as soon as the status becomes ready to play.
|
|
#expect(item.outputs.count == 1)
|
|
}
|
|
|
|
#if os(iOS)
|
|
@Test func videoPlayerShouldNotOverwritePlayAndRecordNorDefaultToSpeaker() throws {
|
|
let stubFactory = StubFVPAVFactory()
|
|
let audioSession = TestAudioSession()
|
|
stubFactory.audioSession = audioSession
|
|
audioSession.category = .playAndRecord
|
|
audioSession.categoryOptions = .defaultToSpeaker
|
|
let videoPlayerPlugin = try createInitializedPlugin(avFactory: stubFactory)
|
|
|
|
try videoPlayerPlugin.setMixWithOthers(true)
|
|
#expect(audioSession.category == .playAndRecord)
|
|
#expect(audioSession.categoryOptions.contains(.defaultToSpeaker))
|
|
#expect(audioSession.categoryOptions.contains(.mixWithOthers))
|
|
}
|
|
|
|
@Test func setMixWithOthersShouldNoOpWhenNoChangesAreRequired() throws {
|
|
let stubFactory = StubFVPAVFactory()
|
|
let audioSession = TestAudioSession()
|
|
stubFactory.audioSession = audioSession
|
|
audioSession.category = .playAndRecord
|
|
audioSession.categoryOptions = [.mixWithOthers, .defaultToSpeaker]
|
|
let videoPlayerPlugin = try createInitializedPlugin(avFactory: stubFactory)
|
|
|
|
try videoPlayerPlugin.setMixWithOthers(true)
|
|
#expect(!audioSession.setCategoryCalled)
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Audio Track Tests
|
|
|
|
// Tests getAudioTracks with a regular MP4 video file using real AVFoundation.
|
|
// Regular MP4 files do not have media selection groups, so getAudioTracks returns an empty array.
|
|
@Test func getAudioTracksWithRealMP4Video() async throws {
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let testURL = try #require(URL(string: mp4TestURI))
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: testURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
await withCheckedContinuation { continuation in
|
|
let listener = StubEventListener(onInitialized: { continuation.resume() })
|
|
player.eventListener = listener
|
|
}
|
|
|
|
// Now test getAudioTracks
|
|
var error: FlutterError?
|
|
let result = try #require(player.getAudioTracks(&error))
|
|
#expect(error == nil)
|
|
|
|
// Regular MP4 files do not have media selection groups for audio.
|
|
// getAudioTracks only returns selectable audio tracks from HLS streams.
|
|
#expect(result.count == 0)
|
|
|
|
player.disposeWithError(&error)
|
|
}
|
|
|
|
// Tests getAudioTracks with an HLS stream using real AVFoundation.
|
|
// HLS streams use media selection groups for audio track selection.
|
|
@Test func getAudioTracksWithRealHLSStream() async throws {
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let hlsURL = try #require(URL(string: hlsTestURI))
|
|
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: hlsURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
await withCheckedContinuation { continuation in
|
|
let listener = StubEventListener(onInitialized: { continuation.resume() })
|
|
player.eventListener = listener
|
|
}
|
|
|
|
// Now test getAudioTracks
|
|
var error: FlutterError?
|
|
let result = try #require(player.getAudioTracks(&error))
|
|
#expect(error == nil)
|
|
|
|
// For HLS streams with multiple audio options, we get media selection tracks.
|
|
// The bee.m3u8 stream may or may not have multiple audio tracks.
|
|
// We verify the method returns valid data without crashing.
|
|
for track in result {
|
|
#expect(track.displayName != nil)
|
|
#expect(track.index >= 0)
|
|
}
|
|
|
|
player.disposeWithError(&error)
|
|
}
|
|
|
|
// Tests that getAudioTracks returns valid data for audio-only files.
|
|
// Regular audio files do not have media selection groups, so getAudioTracks returns an empty array.
|
|
@Test func getAudioTracksWithRealAudioFile() async throws {
|
|
// TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
|
|
// can use a fake item/asset instead of loading an actual remote asset.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let audioURL = try #require(URL(string: mp3AudioTestURI))
|
|
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: audioURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
await withCheckedContinuation { continuation in
|
|
let listener = StubEventListener(onInitialized: { continuation.resume() })
|
|
player.eventListener = listener
|
|
}
|
|
|
|
// Now test getAudioTracks
|
|
var error: FlutterError?
|
|
let result = try #require(player.getAudioTracks(&error))
|
|
#expect(error == nil)
|
|
|
|
// Regular audio files do not have media selection groups.
|
|
// getAudioTracks only returns selectable audio tracks from HLS streams.
|
|
#expect(result.count == 0)
|
|
|
|
player.disposeWithError(&error)
|
|
}
|
|
|
|
// Tests that getAudioTracks works correctly through the plugin API with a real video.
|
|
// Regular MP4 files do not have media selection groups, so getAudioTracks returns an empty array.
|
|
@Test func getAudioTracksViaPluginWithRealVideo() async throws {
|
|
// TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
|
|
// can use a fake item/asset instead of loading an actual remote asset.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let testURL = try #require(URL(string: mp4TestURI))
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: testURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
// Wait for player to become ready
|
|
let listener = StubEventListener()
|
|
await withCheckedContinuation { continuation in
|
|
listener.onInitialized = { continuation.resume() }
|
|
player.eventListener = listener
|
|
}
|
|
|
|
// Now test getAudioTracks
|
|
var error: FlutterError?
|
|
let result = try #require(player.getAudioTracks(&error))
|
|
#expect(error == nil)
|
|
|
|
// Regular MP4 files do not have media selection groups.
|
|
// getAudioTracks only returns selectable audio tracks from HLS streams.
|
|
#expect(result.count == 0)
|
|
|
|
player.disposeWithError(&error)
|
|
}
|
|
|
|
@Test func loadTracksWithMediaTypeIsCalledOnNewerOS() {
|
|
if #available(iOS 15.0, macOS 12.0, *) {
|
|
let mockAsset = TestAsset(duration: CMTimeMake(value: 1, timescale: 1), tracks: [])
|
|
let item = StubPlayerItem(asset: mockAsset)
|
|
|
|
let stubAVFactory = StubFVPAVFactory(player: nil, playerItem: item, pixelBufferSource: nil)
|
|
let stubViewProvider = StubViewProvider()
|
|
let _ = FVPVideoPlayer(
|
|
playerItem: item, avFactory: stubAVFactory, viewProvider: stubViewProvider)
|
|
#expect(mockAsset.loadedTracksAsynchronously)
|
|
}
|
|
}
|
|
|
|
@Test func videoOutputIsConfiguredWithBT709ColorProperties() throws {
|
|
let item = StubPlayerItem()
|
|
let stubAVFactory = StubFVPAVFactory(player: nil, playerItem: item, pixelBufferSource: nil)
|
|
let stubViewProvider = StubViewProvider()
|
|
let _ = FVPVideoPlayer(
|
|
playerItem: item, avFactory: stubAVFactory, viewProvider: stubViewProvider)
|
|
|
|
// BT.709 color properties are required so AVFoundation tone-maps HDR sources
|
|
// into the Flutter texture. Without them HDR samples arrive unconverted and
|
|
// render washed out. See flutter/flutter#91241.
|
|
let settings = try #require(stubAVFactory.lastOutputSettings)
|
|
let colorProperties = try #require(
|
|
settings[AVVideoColorPropertiesKey] as? [String: Any])
|
|
#expect(
|
|
colorProperties[AVVideoColorPrimariesKey] as? String == AVVideoColorPrimaries_ITU_R_709_2)
|
|
#expect(
|
|
colorProperties[AVVideoTransferFunctionKey] as? String
|
|
== AVVideoTransferFunction_ITU_R_709_2)
|
|
#expect(
|
|
colorProperties[AVVideoYCbCrMatrixKey] as? String == AVVideoYCbCrMatrix_ITU_R_709_2)
|
|
}
|
|
|
|
// MARK: - Video Track Tests
|
|
|
|
// Integration test for getVideoTracks with a non-HLS MP4 video over a live network request.
|
|
// Non-HLS MP4 files don't have adaptive bitrate variants, so we expect empty media selection
|
|
// tracks.
|
|
@Test func getVideoTracksWithRealMP4Video() async throws {
|
|
// TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
|
|
// can use a fake item/asset instead of loading an actual remote asset.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let testURL = try #require(URL(string: mp4TestURI))
|
|
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: testURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
await withCheckedContinuation { continuation in
|
|
let listener = StubEventListener(onInitialized: { continuation.resume() })
|
|
player.eventListener = listener
|
|
}
|
|
|
|
// Now test getVideoTracks
|
|
await withCheckedContinuation { continuation in
|
|
player.getVideoTracks { result, error in
|
|
#expect(error == nil)
|
|
#expect(result != nil)
|
|
// For regular MP4 files, media selection tracks should be nil (no HLS variants)
|
|
// The method returns empty data for non-HLS content
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
var disposeError: FlutterError?
|
|
player.disposeWithError(&disposeError)
|
|
}
|
|
|
|
// Integration test for getVideoTracks with an HLS stream over a live network request.
|
|
// HLS streams use AVAssetVariant API (iOS 15+) to enumerate available quality variants.
|
|
@Test func getVideoTracksWithRealHLSStream() async throws {
|
|
// TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
|
|
// can use a fake item/asset instead of loading an actual remote asset.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let hlsURL = try #require(URL(string: hlsTestURI))
|
|
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: hlsURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
await withCheckedContinuation { continuation in
|
|
let listener = StubEventListener(onInitialized: { continuation.resume() })
|
|
player.eventListener = listener
|
|
}
|
|
|
|
// Now test getVideoTracks
|
|
await withCheckedContinuation { continuation in
|
|
player.getVideoTracks { result, error in
|
|
#expect(error == nil)
|
|
#expect(result != nil)
|
|
|
|
// For HLS streams on iOS 15+, we may have media selection tracks (variants)
|
|
if #available(iOS 15.0, macOS 12.0, *) {
|
|
// The bee.m3u8 stream may or may not have multiple video variants.
|
|
// We verify the method returns valid data without crashing.
|
|
if let mediaSelectionTracks = result?.mediaSelectionTracks {
|
|
// If media selection tracks exist, they should have valid structure
|
|
for track in mediaSelectionTracks {
|
|
#expect(track.variantIndex >= 0)
|
|
// Bitrate should be positive if present
|
|
if let bitrate = track.bitrate {
|
|
#expect(bitrate.intValue > 0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
var disposeError: FlutterError?
|
|
player.disposeWithError(&disposeError)
|
|
}
|
|
|
|
// Tests selectVideoTrack sets preferredPeakBitRate correctly.
|
|
@Test func selectVideoTrackSetsBitrate() async throws {
|
|
// TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
|
|
// can use a fake item/asset instead of loading an actual remote asset.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let testURL = try #require(URL(string: mp4TestURI))
|
|
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: testURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
await withCheckedContinuation { continuation in
|
|
let listener = StubEventListener(onInitialized: { continuation.resume() })
|
|
player.eventListener = listener
|
|
}
|
|
|
|
var error: FlutterError?
|
|
// Set a specific bitrate
|
|
player.selectVideoTrack(withBitrate: 5_000_000, error: &error)
|
|
#expect(error == nil)
|
|
#expect(player.player.currentItem?.preferredPeakBitRate == 5_000_000)
|
|
|
|
player.disposeWithError(&error)
|
|
}
|
|
|
|
// Tests selectVideoTrack with 0 bitrate enables auto quality selection.
|
|
@Test func selectVideoTrackAutoQuality() async throws {
|
|
// TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
|
|
// can use a fake item/asset instead of loading an actual remote asset.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let testURL = try #require(URL(string: mp4TestURI))
|
|
|
|
let player = FVPVideoPlayer(
|
|
playerItem: playerItem(with: testURL, factory: realObjectFactory),
|
|
avFactory: realObjectFactory,
|
|
viewProvider: StubViewProvider())
|
|
|
|
await withCheckedContinuation { continuation in
|
|
let listener = StubEventListener(onInitialized: { continuation.resume() })
|
|
player.eventListener = listener
|
|
}
|
|
|
|
var error: FlutterError?
|
|
// First set a specific bitrate
|
|
player.selectVideoTrack(withBitrate: 5_000_000, error: &error)
|
|
#expect(error == nil)
|
|
#expect(player.player.currentItem?.preferredPeakBitRate == 5_000_000)
|
|
|
|
// Then set to auto quality (0)
|
|
player.selectVideoTrack(withBitrate: 0, error: &error)
|
|
#expect(error == nil)
|
|
#expect(player.player.currentItem?.preferredPeakBitRate == 0)
|
|
|
|
player.disposeWithError(&error)
|
|
}
|
|
|
|
// Tests that getVideoTracks works correctly through the plugin API with a real video.
|
|
@Test func getVideoTracksViaPluginWithRealVideo() async throws {
|
|
// TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
|
|
// can use a fake item/asset instead of loading an actual remote asset.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)
|
|
|
|
var error: FlutterError?
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
|
|
|
|
let player = videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPVideoPlayer
|
|
#expect(player != nil)
|
|
|
|
// Wait for player item to become ready
|
|
let item = try #require(player.player.currentItem)
|
|
try await waitForPlayerItemStatus(item, state: .readyToPlay)
|
|
|
|
// Now test getVideoTracks
|
|
await withCheckedContinuation { continuation in
|
|
player.getVideoTracks { result, error in
|
|
#expect(error == nil)
|
|
#expect(result != nil)
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
player.disposeWithError(&error)
|
|
}
|
|
|
|
// Tests selectVideoTrack via plugin API with HLS stream.
|
|
@Test func selectVideoTrackViaPluginWithHLSStream() async throws {
|
|
// TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
|
|
// can use a fake item/asset instead of loading an actual remote asset.
|
|
let realObjectFactory = FVPDefaultAVFactory()
|
|
let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)
|
|
|
|
var error: FlutterError?
|
|
// Use HLS stream which supports adaptive bitrate
|
|
let identifiers = try videoPlayerPlugin.createTexturePlayer(
|
|
options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
|
|
|
|
let player = videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPVideoPlayer
|
|
#expect(player != nil)
|
|
|
|
// Wait for player item to become ready
|
|
let item = try #require(player.player.currentItem)
|
|
try await waitForPlayerItemStatus(item, state: .readyToPlay)
|
|
|
|
// Test setting a specific bitrate
|
|
player.selectVideoTrack(withBitrate: 1_000_000, error: &error)
|
|
#expect(error == nil)
|
|
#expect(player.player.currentItem?.preferredPeakBitRate == 1_000_000)
|
|
|
|
// Test setting auto quality
|
|
player.selectVideoTrack(withBitrate: 0, error: &error)
|
|
#expect(error == nil)
|
|
#expect(player.player.currentItem?.preferredPeakBitRate == 0)
|
|
|
|
player.disposeWithError(&error)
|
|
}
|
|
|
|
// MARK: - Helper Methods
|
|
|
|
/// Creates a plugin with the given dependencies, and default stubs for any that aren't provided,
|
|
/// then initializes it.
|
|
private func createInitializedPlugin(
|
|
avFactory: FVPAVFactory = StubFVPAVFactory(),
|
|
displayLinkFactory: DisplayLinkFactory = StubFVPDisplayLinkFactory(),
|
|
binaryMessenger: FlutterBinaryMessenger = StubBinaryMessenger(),
|
|
textureRegistry: FlutterTextureRegistry = TestTextureRegistry(),
|
|
viewProvider: FVPViewProvider = StubViewProvider(),
|
|
assetProvider: FVPAssetProvider = StubAssetProvider()
|
|
) throws -> VideoPlayerPlugin {
|
|
let plugin = VideoPlayerPlugin(
|
|
avFactory: avFactory,
|
|
displayLinkFactory: displayLinkFactory,
|
|
binaryMessenger: binaryMessenger,
|
|
textureRegistry: textureRegistry,
|
|
viewProvider: viewProvider,
|
|
assetProvider: assetProvider)
|
|
try plugin.initialize()
|
|
return plugin
|
|
}
|
|
|
|
private func playerItem(with url: URL, factory: FVPAVFactory) -> FVPAVPlayerItem {
|
|
let asset = factory.urlAsset(with: url, options: nil)
|
|
return factory.playerItem(with: asset)
|
|
}
|
|
|
|
private func waitForPlayerItemStatus(_ item: AVPlayerItem, state: AVPlayerItem.Status)
|
|
async
|
|
throws
|
|
{
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
// Check whether it already has the desired status.
|
|
if item.status == state {
|
|
continuation.resume()
|
|
return
|
|
}
|
|
|
|
if item.status == .failed {
|
|
continuation.resume(throwing: item.error ?? NSError(domain: "VideoPlayerTests", code: 1))
|
|
return
|
|
}
|
|
|
|
// If not, wait for that status. The observation token must be retained until the callback
|
|
// fires; otherwise KVO unregisters immediately and the continuation is never resumed.
|
|
var observation: NSKeyValueObservation?
|
|
observation = item.observe(\.status, options: [.initial, .new]) { observedItem, _ in
|
|
if observedItem.status == state {
|
|
observation?.invalidate()
|
|
observation = nil
|
|
continuation.resume()
|
|
} else if observedItem.status == .failed {
|
|
observation?.invalidate()
|
|
observation = nil
|
|
continuation.resume(
|
|
throwing: observedItem.error ?? NSError(domain: "VideoPlayerTests", code: 1))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO(stuartmorgan): Remove this in favor of just `await player.seek(...)` once
|
|
// Pigeon is generating Swift 6-friendly output. Currently using the automatic async
|
|
// conversion generates warnings due to the lack of concurrency annotations
|
|
// ("non-sendable type 'FlutterError?' returned by implicitly asynchronous call to
|
|
// nonisolated function cannot cross actor boundary").
|
|
private func asyncSeekTo(player: FVPVideoPlayer, time: Int) async {
|
|
await withCheckedContinuation { continuation in
|
|
player.seek(to: time) { error in
|
|
#expect(error == nil)
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
}
|