twonly-app-dependencies/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.swift
2026-08-22 20:30:14 +02:00

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()
}
}
}
}