Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/video_player/video_player_avfoundation/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 2.10.0

* Implements `getVideoTracks()` and `selectVideoTrack()` methods for video track (quality) selection using AVFoundation.
* Video track selection requires iOS 15+ / macOS 12+ for HLS streams.

## 2.9.4

* Ensures that the display link does not continue requesting frames after a player is disposed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,217 @@ private let hlsAudioTestURI =
}
}

// 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 testURL = try #require(URL(string: mp4TestURI))
let videoPlayerPlugin = createInitializedPlugin(avFactory: realObjectFactory)

let create = FVPCreationOptions.make(
withUri: mp4TestURI,
httpHeaders: [:])
var error: FlutterError?
let identifiers = try #require(
videoPlayerPlugin.createTexturePlayer(with: create, error: &error))
#expect(error == nil)

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)
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 = createInitializedPlugin(avFactory: realObjectFactory)

var error: FlutterError?
// Use HLS stream which supports adaptive bitrate
let create = FVPCreationOptions.make(
withUri: hlsTestURI,
httpHeaders: [:])
let identifiers = try #require(
videoPlayerPlugin.createTexturePlayer(with: create, error: &error))
#expect(error == nil)

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)
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#import "./include/video_player_avfoundation/FVPVideoPlayer.h"
#import "./include/video_player_avfoundation/FVPVideoPlayer_Internal.h"

#import <CoreMedia/CoreMedia.h>
#import <GLKit/GLKit.h>

#import "./include/video_player_avfoundation/AVAssetTrackUtils.h"
Expand All @@ -14,6 +15,10 @@
static void *playbackLikelyToKeepUpContext = &playbackLikelyToKeepUpContext;
static void *rateContext = &rateContext;

/// The key name for loading AVURLAsset variants property asynchronously.
/// Note: Apple does not provide a constant for this key; it is documented in the AVURLAsset API.
static NSString *const kFVPAssetVariantsKey = @"variants";

/// Registers KVO observers on 'object' for each entry in 'observations', which must be a
/// dictionary mapping KVO keys to NSValue-wrapped context pointers.
///
Expand Down Expand Up @@ -444,6 +449,144 @@ - (void)setPlaybackSpeed:(double)speed error:(FlutterError *_Nullable *_Nonnull)
[self updatePlayingState];
}

- (void)getVideoTracks:(void (^)(FVPNativeVideoTrackData *_Nullable,
FlutterError *_Nullable))completion {
NSMutableArray<FVPMediaSelectionVideoTrackData *> *mediaSelectionTracks = [NSMutableArray array];

AVPlayerItem *currentItem = _player.currentItem;
if (!currentItem) {
completion(nil, nil);
return;
}

AVURLAsset *urlAsset = (AVURLAsset *)currentItem.asset;
if (![urlAsset isKindOfClass:[AVURLAsset class]]) {
completion(nil, nil);
return;
}

// Use AVAssetVariant API for iOS 15+ to get HLS variants
if (@available(iOS 15.0, macOS 12.0, *)) {
[urlAsset
loadValuesAsynchronouslyForKeys:@[ kFVPAssetVariantsKey ]
completionHandler:^{
dispatch_async(dispatch_get_main_queue(), ^{
NSError *error = nil;
AVKeyValueStatus status =
[urlAsset statusOfValueForKey:kFVPAssetVariantsKey error:&error];

if (status == AVKeyValueStatusLoaded) {
Comment on lines +475 to +478
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The implementation of getVideoTracks does not handle the case where loading the asset variants fails. If status is AVKeyValueStatusFailed, the error parameter of statusOfValueForKey:error: will be populated, but it is currently ignored, and the method proceeds to return an empty result. It would be better to check for failure and return a FlutterError to provide better diagnostics to the Dart side.

NSArray<AVAssetVariant *> *variants = urlAsset.variants;
double currentBitrate = MAX(currentItem.preferredPeakBitRate, 0);

NSInteger variantIndex = 0;
for (AVAssetVariant *variant in variants) {
double peakBitRate = variant.peakBitRate;
CGSize videoSize = CGSizeZero;
double frameRate = 0;
NSString *codec = nil;

// Get video attributes if available
AVAssetVariantVideoAttributes *videoAttrs = variant.videoAttributes;
if (videoAttrs) {
videoSize = videoAttrs.presentationSize;
frameRate = videoAttrs.nominalFrameRate;
// Get codec from media sub types
NSArray *codecTypes = videoAttrs.codecTypes;
if (codecTypes.count > 0) {
FourCharCode codecType = [codecTypes[0] unsignedIntValue];
codec = [self codecStringFromFourCharCode:codecType];
}
}

// Determine if this variant is currently selected by comparing
// bitrates. Since AVPlayer doesn't expose the exact selected variant,
// we use a 10% tolerance to account for minor bitrate variations in
// adaptive streaming.
BOOL isSelected =
(currentBitrate > 0 &&
fabs(peakBitRate - currentBitrate) < peakBitRate * 0.1);

// Generate a human-readable resolution label (e.g., "1080p")
NSString *resolutionLabel = nil;
if (videoSize.height > 0) {
resolutionLabel =
[NSString stringWithFormat:@"%.0fp", videoSize.height];
}

FVPMediaSelectionVideoTrackData *trackData =
[self createVideoTrackDataWithIndex:variantIndex
label:resolutionLabel
peakBitRate:peakBitRate
videoSize:videoSize
frameRate:frameRate
codec:codec
isSelected:isSelected];
[mediaSelectionTracks addObject:trackData];
variantIndex++;
}
}

FVPNativeVideoTrackData *result =
[FVPNativeVideoTrackData makeWithAssetTracks:nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The assetTracks parameter is explicitly set to nil here, which means that for non-HLS videos (or HLS videos where variants aren't used), no video track information will be returned. However, the Pigeon definition and the Dart implementation in AVFoundationVideoPlayer.dart (lines 250-271) include logic to handle assetTracks. This results in dead code on the Dart side. Consider populating assetTracks using [urlAsset tracksWithMediaType:AVMediaTypeVideo] to provide consistent track information for all video types, even if they don't support quality selection.

mediaSelectionTracks:mediaSelectionTracks];
completion(result, nil);
});
}];
} else {
// For iOS < 15, AVAssetVariant API is not available. Return nil (not an error)
// since the absence of variant data is expected on older OS versions.
completion(nil, nil);
}
}

/// Creates a video track data object with the given parameters, converting values to NSNumber
/// where appropriate and returning nil for invalid/zero values.
- (FVPMediaSelectionVideoTrackData *)createVideoTrackDataWithIndex:(NSInteger)index
label:(NSString *)label
peakBitRate:(double)peakBitRate
videoSize:(CGSize)videoSize
frameRate:(double)frameRate
codec:(NSString *)codec
isSelected:(BOOL)isSelected {
return [FVPMediaSelectionVideoTrackData
makeWithVariantIndex:index
label:label
bitrate:peakBitRate > 0 ? @((NSInteger)peakBitRate) : nil
width:videoSize.width > 0 ? @((NSInteger)videoSize.width) : nil
height:videoSize.height > 0 ? @((NSInteger)videoSize.height) : nil
frameRate:frameRate > 0 ? @(frameRate) : nil
codec:codec
isSelected:isSelected];
}

/// Converts a FourCharCode codec type to a human-readable string for display in the UI.
/// These codec names help users understand the video encoding format of each quality variant.
- (NSString *)codecStringFromFourCharCode:(FourCharCode)code {
switch (code) {
case kCMVideoCodecType_H264:
return @"avc1";
case kCMVideoCodecType_HEVC:
return @"hevc";
case kCMVideoCodecType_VP9:
return @"vp9";
default:
return nil;
}
}

- (void)selectVideoTrackWithBitrate:(NSInteger)bitrate
error:(FlutterError *_Nullable *_Nonnull)error {
AVPlayerItem *currentItem = _player.currentItem;
if (!currentItem) {
return;
}

// Set preferredPeakBitRate to select the quality
// 0 means auto quality (adaptive streaming)
currentItem.preferredPeakBitRate = (double)bitrate;
}

- (nullable NSArray<FVPMediaSelectionAudioTrackData *> *)getAudioTracks:
(FlutterError *_Nullable *_Nonnull)error {
AVPlayerItem *currentItem = _player.currentItem;
Expand Down
Loading