Skip to content

[video_player_avfoundation] Make sure the AVPlayerItem is .readyToPlay before emitting an initialized event #9534

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
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
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.8.1

* Removes unnecessary workarounds, fixes "initialized" event not firing when the duration of the media is 0.
Copy link
Contributor

Choose a reason for hiding this comment

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

Removes unnecessary workarounds

What workarounds?

Copy link
Contributor Author

@LongCatIsLooong LongCatIsLooong Jul 9, 2025

Choose a reason for hiding this comment

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

The size / duration checks removed in the sendInitialized method.


## 2.8.0

* Adds platform view support for macOS.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,39 @@ - (void)testPlayerShouldNotDropEverySecondFrame {
OCMVerifyAllWithDelay(mockTextureRegistry, 10);
}

- (void)testVideoOutputIsAddedWhenAVPlayerItemBecomesReady {
NSObject<FlutterPluginRegistrar> *registrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar));
FVPVideoPlayerPlugin *videoPlayerPlugin =
[[FVPVideoPlayerPlugin alloc] initWithRegistrar:registrar];
FlutterError *error;
[videoPlayerPlugin initialize:&error];
XCTAssertNil(error);
FVPCreationOptions *create = [FVPCreationOptions
makeWithAsset:nil
uri:@"https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4"
packageName:nil
formatHint:nil
httpHeaders:@{}
viewType:FVPPlatformVideoViewTypeTextureView];

NSNumber *playerIdentifier = [videoPlayerPlugin createWithOptions:create error:&error];
FVPVideoPlayer *player = videoPlayerPlugin.playersByIdentifier[playerIdentifier];
XCTAssertNotNil(player);

AVPlayerItem *item = player.player.currentItem;
[self keyValueObservingExpectationForObject:(id)item
keyPath:@"status"
expectedValue:@(AVPlayerItemStatusReadyToPlay)];
[self waitForExpectationsWithTimeout:10.0 handler:nil];
// Video output is added as soon as the status becomes ready to play.
XCTAssertEqual(item.outputs.count, 1);

[player onListenWithArguments:nil
eventSink:^(FlutterError *event){
}];
XCTAssertEqual(item.outputs.count, 1);
}

#if TARGET_OS_IOS
- (void)testVideoPlayerShouldNotOverwritePlayAndRecordNorDefaultToSpeaker {
NSObject<FlutterPluginRegistrar> *registrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@

static void *timeRangeContext = &timeRangeContext;
static void *statusContext = &statusContext;
static void *presentationSizeContext = &presentationSizeContext;
static void *durationContext = &durationContext;
static void *playbackLikelyToKeepUpContext = &playbackLikelyToKeepUpContext;
static void *rateContext = &rateContext;

Expand Down Expand Up @@ -144,14 +142,6 @@ - (void)addObserversForItem:(AVPlayerItem *)item player:(AVPlayer *)player {
forKeyPath:@"status"
options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew
context:statusContext];
[item addObserver:self
forKeyPath:@"presentationSize"
options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew
context:presentationSizeContext];
[item addObserver:self
forKeyPath:@"duration"
options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew
context:durationContext];
[item addObserver:self
forKeyPath:@"playbackLikelyToKeepUp"
options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew
Expand All @@ -176,6 +166,7 @@ - (void)itemDidPlayToEndTime:(NSNotification *)notification {
AVPlayerItem *p = [notification object];
[p seekToTime:kCMTimeZero completionHandler:nil];
} else {
NSAssert([NSThread isMainThread], @"event sink must only be accessed from the main thread");
if (_eventSink) {
_eventSink(@{@"event" : @"completed"});
}
Expand Down Expand Up @@ -245,56 +236,53 @@ - (void)observeValueForKeyPath:(NSString *)path
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
NSAssert([NSThread isMainThread], @"event sink must only be accessed from the main thread");
if (context == timeRangeContext) {
if (_eventSink != nil) {
NSMutableArray<NSArray<NSNumber *> *> *values = [[NSMutableArray alloc] init];
for (NSValue *rangeValue in [object loadedTimeRanges]) {
CMTimeRange range = [rangeValue CMTimeRangeValue];
int64_t start = FVPCMTimeToMillis(range.start);
[values addObject:@[ @(start), @(start + FVPCMTimeToMillis(range.duration)) ]];
}
_eventSink(@{@"event" : @"bufferingUpdate", @"values" : values});
if (!_eventSink) {
return;
}
NSMutableArray<NSArray<NSNumber *> *> *values = [[NSMutableArray alloc] init];
for (NSValue *rangeValue in [object loadedTimeRanges]) {
CMTimeRange range = [rangeValue CMTimeRangeValue];
int64_t start = FVPCMTimeToMillis(range.start);
[values addObject:@[ @(start), @(start + FVPCMTimeToMillis(range.duration)) ]];
}
_eventSink(@{@"event" : @"bufferingUpdate", @"values" : values});
} else if (context == statusContext) {
AVPlayerItem *item = (AVPlayerItem *)object;
switch (item.status) {
case AVPlayerItemStatusFailed:
[self sendFailedToLoadVideoEvent];
break;
case AVPlayerItemStatusUnknown:
break;
case AVPlayerItemStatusReadyToPlay:
[item addOutput:_videoOutput];
[self setupEventSinkIfReadyToPlay];
if (![item.outputs containsObject:_videoOutput]) {
[item addOutput:_videoOutput];
}
if (_eventSink) {
[self sendVideoInitializedEvent];
}
break;
case AVPlayerItemStatusFailed:
if (_eventSink) {
[self sendFailedToLoadVideoEvent];
}
break;
}
} else if (context == presentationSizeContext || context == durationContext) {
AVPlayerItem *item = (AVPlayerItem *)object;
if (item.status == AVPlayerItemStatusReadyToPlay) {
// Due to an apparent bug, when the player item is ready, it still may not have determined
// its presentation size or duration. When these properties are finally set, re-check if
// all required properties and instantiate the event sink if it is not already set up.
[self setupEventSinkIfReadyToPlay];
}
} else if (context == playbackLikelyToKeepUpContext) {
[self updatePlayingState];
if ([[_player currentItem] isPlaybackLikelyToKeepUp]) {
if (_eventSink != nil) {
_eventSink(@{@"event" : @"bufferingEnd"});
}
} else {
if (_eventSink != nil) {
_eventSink(@{@"event" : @"bufferingStart"});
}
if (!_eventSink) {
return;
}
[self updatePlayingState];
NSString *event =
[[_player currentItem] isPlaybackLikelyToKeepUp] ? @"bufferingEnd" : @"bufferingStart";
_eventSink(@{@"event" : event});
} else if (context == rateContext) {
if (!_eventSink) {
return;
}
// Important: Make sure to cast the object to AVPlayer when observing the rate property,
// as it is not available in AVPlayerItem.
AVPlayer *player = (AVPlayer *)object;
if (_eventSink != nil) {
_eventSink(
@{@"event" : @"isPlayingStateUpdate", @"isPlaying" : player.rate > 0 ? @YES : @NO});
}
_eventSink(@{@"event" : @"isPlayingStateUpdate", @"isPlaying" : player.rate > 0 ? @YES : @NO});
}
}

Expand Down Expand Up @@ -344,9 +332,7 @@ - (void)updateRate {
}

- (void)sendFailedToLoadVideoEvent {
if (_eventSink == nil) {
return;
}
NSAssert(_eventSink, @"sendFailedToLoadVideoEvent was called when the event sink was nil.");
// Prefer more detailed error information from tracks loading.
NSError *error;
if ([self.player.currentItem.asset statusOfValueForKey:@"tracks"
Expand All @@ -369,58 +355,24 @@ - (void)sendFailedToLoadVideoEvent {
_eventSink([FlutterError errorWithCode:@"VideoError" message:message details:nil]);
}

- (void)setupEventSinkIfReadyToPlay {
if (_eventSink && !_isInitialized) {
AVPlayerItem *currentItem = self.player.currentItem;
CGSize size = currentItem.presentationSize;
CGFloat width = size.width;
CGFloat height = size.height;

// Wait until tracks are loaded to check duration or if there are any videos.
AVAsset *asset = currentItem.asset;
if ([asset statusOfValueForKey:@"tracks" error:nil] != AVKeyValueStatusLoaded) {
void (^trackCompletionHandler)(void) = ^{
if ([asset statusOfValueForKey:@"tracks" error:nil] != AVKeyValueStatusLoaded) {
// Cancelled, or something failed.
return;
}
// This completion block will run on an AVFoundation background queue.
// Hop back to the main thread to set up event sink.
[self performSelector:_cmd onThread:NSThread.mainThread withObject:self waitUntilDone:NO];
};
[asset loadValuesAsynchronouslyForKeys:@[ @"tracks" ]
completionHandler:trackCompletionHandler];
return;
}

BOOL hasVideoTracks = [asset tracksWithMediaType:AVMediaTypeVideo].count != 0;
// Audio-only HLS files have no size, so `currentItem.tracks.count` must be used to check for
// track presence, as AVAsset does not always provide track information in HLS streams.
BOOL hasNoTracks = currentItem.tracks.count == 0 && asset.tracks.count == 0;

// The player has not yet initialized when it has no size, unless it is an audio-only track.
// HLS m3u8 video files never load any tracks, and are also not yet initialized until they have
// a size.
if ((hasVideoTracks || hasNoTracks) && height == CGSizeZero.height &&
width == CGSizeZero.width) {
return;
}
// The player may be initialized but still needs to determine the duration.
int64_t duration = [self duration];
if (duration == 0) {
return;
}

_isInitialized = YES;
[self updatePlayingState];

_eventSink(@{
@"event" : @"initialized",
@"duration" : @(duration),
@"width" : @(width),
@"height" : @(height)
});
}
- (void)sendVideoInitializedEvent {
AVPlayerItem *currentItem = self.player.currentItem;
NSAssert(currentItem.status == AVPlayerItemStatusReadyToPlay,
@"sendVideoInitializedEvent was called when the item wasn't ready to play.");
NSAssert(_eventSink, @"sendVideoInitializedEvent was called when the event sink was nil.");
NSAssert(!_isInitialized, @"sendVideoInitializedEvent should only be called once.");
CGSize size = currentItem.presentationSize;
CGFloat width = size.width;
CGFloat height = size.height;

Copy link
Contributor Author

@LongCatIsLooong LongCatIsLooong Jul 7, 2025

Choose a reason for hiding this comment

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

Removed the duration / size checks since now the caller has to guarantee the item is .readyToPlay and I think those checks shouldn't be necessary.

Before this you may enter this if block even when the item's status is .unknown and I believe that's why we added the workarounds (size / duration checks).

_isInitialized = YES;
[self updatePlayingState];
_eventSink(@{
@"event" : @"initialized",
@"duration" : @(self.duration),
@"width" : @(width),
@"height" : @(height)
});
}

#pragma mark - FVPVideoPlayerInstanceApi
Expand Down Expand Up @@ -474,27 +426,32 @@ - (void)setPlaybackSpeed:(double)speed error:(FlutterError *_Nullable *_Nonnull)
#pragma mark - FlutterStreamHandler

- (FlutterError *_Nullable)onCancelWithArguments:(id _Nullable)arguments {
NSAssert([NSThread isMainThread], @"event sink must only be accessed from the main thread");
_eventSink = nil;
_isInitialized = NO;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

_isInitialized is now reset to NO on onListenWithArguments. According to the documentation:

The channel implementation may call this method with nil arguments to separate a pair of two consecutive set up requests. Such request pairs may occur during Flutter hot restart.

So this makes sure the new event sink gets an initialized event in case it's a hot restart (in which case I think the dart vm will also restart erasing all user states?). But if the engine is allowed to call onCancelWithArguments and onListenWithArguments whenever it wants then the dart side may receive more than one initialized event.

return nil;
}

- (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments
eventSink:(nonnull FlutterEventSink)events {
NSAssert([NSThread isMainThread], @"event sink must only be accessed from the main thread");
_eventSink = events;
// TODO(@recastrodiaz): remove the line below when the race condition is resolved:
// https://github.com/flutter/flutter/issues/21483
// This line ensures the 'initialized' event is sent when the event
// 'AVPlayerItemStatusReadyToPlay' fires before _eventSink is set (this function
// onListenWithArguments is called)
// and also send error in similar case with 'AVPlayerItemStatusFailed'
// https://github.com/flutter/flutter/issues/151475
// https://github.com/flutter/flutter/issues/147707
if (self.player.currentItem.status == AVPlayerItemStatusFailed) {
[self sendFailedToLoadVideoEvent];
return nil;
switch (self.player.currentItem.status) {
case AVPlayerItemStatusUnknown:
// When this method is called when the media is still loading, do nothing:
// sendVideoInitializedEvent or sendFailedToLoadVideoEvent will be called
// by KVO on status updates.
return nil;
case AVPlayerItemStatusReadyToPlay:
[self sendVideoInitializedEvent];
return nil;
case AVPlayerItemStatusFailed:
[self sendFailedToLoadVideoEvent];
return nil;
default:
NSAssert(NO, @"Unknown AVPlayerItemStatus: %ld", (long)self.player.currentItem.status);
return nil;
}
[self setupEventSinkIfReadyToPlay];
return nil;
}

#pragma mark - Private
Expand All @@ -513,8 +470,6 @@ - (void)removeKeyValueObservers {
AVPlayerItem *currentItem = _player.currentItem;
[currentItem removeObserver:self forKeyPath:@"status"];
[currentItem removeObserver:self forKeyPath:@"loadedTimeRanges"];
[currentItem removeObserver:self forKeyPath:@"presentationSize"];
[currentItem removeObserver:self forKeyPath:@"duration"];
[currentItem removeObserver:self forKeyPath:@"playbackLikelyToKeepUp"];
[_player removeObserver:self forKeyPath:@"rate"];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ NS_ASSUME_NONNULL_BEGIN
@property(nonatomic, readonly) NSNumber *targetPlaybackSpeed;
/// Indicates whether the video player is currently playing.
@property(nonatomic, readonly) BOOL isPlaying;
/// Indicates whether the video player has been initialized.
/// Indicates whether an "initialized" message has been sent to the current Flutter event sink.
///
/// The video player sends an "initialized" message to the event sink when its underlying
/// AVPlayerItem is ready to play and the event sink is set to a non-nil value, whichever occurs
/// last.
///
/// This flag is set back to NO when event sink is set to nil in onCancelWithArgument.
@property(nonatomic, readonly) BOOL isInitialized;

/// Initializes a new instance of FVPVideoPlayer with the given AVPlayerItem, frame updater, display
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import 'dart:async';
import 'dart:io';
import 'dart:math' as math show max;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -526,29 +527,27 @@ class _VideoProgressIndicatorState extends State<VideoProgressIndicator> {
const Color bufferedColor = Color.fromRGBO(50, 50, 200, 0.2);
const Color backgroundColor = Color.fromRGBO(200, 200, 200, 0.5);

Widget progressIndicator;
final Widget progressIndicator;
if (controller.value.isInitialized) {
final int duration = controller.value.duration.inMilliseconds;
final int position = controller.value.position.inMilliseconds;

int maxBuffering = 0;
for (final DurationRange range in controller.value.buffered) {
final int end = range.end.inMilliseconds;
if (end > maxBuffering) {
maxBuffering = end;
}
}

final double maxBuffering = duration == 0.0
? 0.0
: controller.value.buffered
.map((DurationRange range) => range.end.inMilliseconds)
.fold(0, math.max) /
duration;
progressIndicator = Stack(
fit: StackFit.passthrough,
children: <Widget>[
LinearProgressIndicator(
value: maxBuffering / duration,
value: maxBuffering,
valueColor: const AlwaysStoppedAnimation<Color>(bufferedColor),
backgroundColor: backgroundColor,
),
LinearProgressIndicator(
value: position / duration,
value: duration == 0.0 ? 0.0 : position / duration,
valueColor: const AlwaysStoppedAnimation<Color>(playedColor),
backgroundColor: Colors.transparent,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ name: video_player_avfoundation
description: iOS and macOS implementation of the video_player plugin.
repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_avfoundation
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22
version: 2.8.0
version: 2.8.1


environment:
sdk: ^3.6.0
Expand Down