Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
Comment thread
nateshmbhat marked this conversation as resolved.
Outdated
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) {
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
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