Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.2

* Restructure internals of Dart notification of video player events.

## 2.8.1

* Restructures internal logic to move more code to Dart.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#import <OCMock/OCMock.h>
#import <video_player_avfoundation/AVAssetTrackUtils.h>
#import <video_player_avfoundation/FVPEventBridge.h>
#import <video_player_avfoundation/FVPNativeVideoViewFactory.h>
#import <video_player_avfoundation/FVPTextureBasedVideoPlayer_Test.h>
#import <video_player_avfoundation/FVPVideoPlayerPlugin_Test.h>
Expand Down Expand Up @@ -166,6 +167,55 @@ - (instancetype)init {

#pragma mark -

@interface StubEventListener : NSObject <FVPVideoEventListener>

@property(nonatomic) XCTestExpectation *initializationExpectation;
@property(nonatomic) int64_t initializationDuration;
@property(nonatomic) CGSize initialiaztionSize;

- (instancetype)initWithInitalizationExpectation:(XCTestExpectation *)expectation;

@end

@implementation StubEventListener

- (instancetype)initWithInitalizationExpectation:(XCTestExpectation *)expectation {
self = [super init];
_initializationExpectation = expectation;
return self;
}

- (void)videoPlayerDidComplete {
}

- (void)videoPlayerDidEndBuffering {
}

- (void)videoPlayerDidErrorWithMessage:(NSString *)errorMessage {
}

- (void)videoPlayerDidInitializeWithDuration:(int64_t)duration size:(CGSize)size {
[self.initializationExpectation fulfill];
self.initializationDuration = duration;
self.initialiaztionSize = size;
}

- (void)videoPlayerDidSetPlaying:(BOOL)playing {
}

- (void)videoPlayerDidStartBuffering {
}

- (void)videoPlayerDidUpdateBufferRegions:(NSArray<NSArray<NSValue *> *> *)regions {
}

- (void)videoPlayerWasDisposed {
}

@end

#pragma mark -

@implementation VideoPlayerTests

- (void)testBlankVideoBugWithEncryptedVideoStreamAndInvertedAspectRatioBugForSomeVideoStream {
Expand Down Expand Up @@ -477,16 +527,19 @@ - (void)testBufferingStateFromPlayer {
AVPlayer *avPlayer = player.player;
[avPlayer play];

[player onListenWithArguments:nil
eventSink:^(NSDictionary<NSString *, id> *event) {
if ([event[@"event"] isEqualToString:@"bufferingEnd"]) {
XCTAssertTrue(avPlayer.currentItem.isPlaybackLikelyToKeepUp);
}

if ([event[@"event"] isEqualToString:@"bufferingStart"]) {
XCTAssertFalse(avPlayer.currentItem.isPlaybackLikelyToKeepUp);
}
}];
// TODO(stuartmorgan): Update this test to instead use a mock listener, and add separate unit
// tests of FVPEventBridge.
[(NSObject<FlutterStreamHandler> *)player.eventListener
onListenWithArguments:nil
eventSink:^(NSDictionary<NSString *, id> *event) {
if ([event[@"event"] isEqualToString:@"bufferingEnd"]) {
XCTAssertTrue(avPlayer.currentItem.isPlaybackLikelyToKeepUp);
}

if ([event[@"event"] isEqualToString:@"bufferingStart"]) {
XCTAssertFalse(avPlayer.currentItem.isPlaybackLikelyToKeepUp);
}
}];
XCTestExpectation *bufferingStateExpectation =
[self expectationWithDescription:@"bufferingState"];
NSTimeInterval timeout = 10;
Expand All @@ -498,39 +551,39 @@ - (void)testBufferingStateFromPlayer {
}

- (void)testVideoControls {
NSDictionary<NSString *, id> *videoInitialization =
StubEventListener *eventListener =
[self sanityTestURI:@"https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4"];
XCTAssertEqualObjects(videoInitialization[@"height"], @720);
XCTAssertEqualObjects(videoInitialization[@"width"], @1280);
XCTAssertEqualWithAccuracy([videoInitialization[@"duration"] intValue], 4000, 200);
XCTAssertEqual(eventListener.initialiaztionSize.height, 720);
XCTAssertEqual(eventListener.initialiaztionSize.width, 1280);
XCTAssertEqualWithAccuracy(eventListener.initializationDuration, 4000, 200);
}

- (void)testAudioControls {
NSDictionary<NSString *, id> *audioInitialization = [self
StubEventListener *eventListener = [self
sanityTestURI:@"https://flutter.github.io/assets-for-api-docs/assets/audio/rooster.mp3"];
XCTAssertEqualObjects(audioInitialization[@"height"], @0);
XCTAssertEqualObjects(audioInitialization[@"width"], @0);
XCTAssertEqual(eventListener.initialiaztionSize.height, 0);
XCTAssertEqual(eventListener.initialiaztionSize.width, 0);
// Perfect precision not guaranteed.
XCTAssertEqualWithAccuracy([audioInitialization[@"duration"] intValue], 5400, 200);
XCTAssertEqualWithAccuracy(eventListener.initializationDuration, 5400, 200);
}

- (void)testHLSControls {
NSDictionary<NSString *, id> *videoInitialization = [self
StubEventListener *eventListener = [self
sanityTestURI:@"https://flutter.github.io/assets-for-api-docs/assets/videos/hls/bee.m3u8"];
XCTAssertEqualObjects(videoInitialization[@"height"], @720);
XCTAssertEqualObjects(videoInitialization[@"width"], @1280);
XCTAssertEqualWithAccuracy([videoInitialization[@"duration"] intValue], 4000, 200);
XCTAssertEqual(eventListener.initialiaztionSize.height, 720);
XCTAssertEqual(eventListener.initialiaztionSize.width, 1280);
XCTAssertEqualWithAccuracy(eventListener.initializationDuration, 4000, 200);
}

- (void)testAudioOnlyHLSControls {
XCTSkip(@"Flaky; see https://github.com/flutter/flutter/issues/164381");

NSDictionary<NSString *, id> *videoInitialization =
StubEventListener *eventListener =
[self sanityTestURI:@"https://flutter.github.io/assets-for-api-docs/assets/videos/hls/"
@"bee_audio_only.m3u8"];
XCTAssertEqualObjects(videoInitialization[@"height"], @0);
XCTAssertEqualObjects(videoInitialization[@"width"], @0);
XCTAssertEqualWithAccuracy([videoInitialization[@"duration"] intValue], 4000, 200);
XCTAssertEqual(eventListener.initialiaztionSize.height, 0);
XCTAssertEqual(eventListener.initialiaztionSize.width, 0);
XCTAssertEqualWithAccuracy(eventListener.initializationDuration, 4000, 200);
}

#if TARGET_OS_IOS
Expand All @@ -555,6 +608,8 @@ - (void)testSeekToleranceWhenNotSeekingToEnd {
httpHeaders:@{}
avFactory:stubAVFactory
viewProvider:[[StubViewProvider alloc] initWithView:nil]];
NSObject<FVPVideoEventListener> *listener = OCMProtocolMock(@protocol(FVPVideoEventListener));
player.eventListener = listener;

XCTestExpectation *seekExpectation =
[self expectationWithDescription:@"seekTo has zero tolerance when seeking not to end"];
Expand All @@ -577,6 +632,8 @@ - (void)testSeekToleranceWhenSeekingToEnd {
httpHeaders:@{}
avFactory:stubAVFactory
viewProvider:[[StubViewProvider alloc] initWithView:nil]];
NSObject<FVPVideoEventListener> *listener = OCMProtocolMock(@protocol(FVPVideoEventListener));
player.eventListener = listener;

XCTestExpectation *seekExpectation =
[self expectationWithDescription:@"seekTo has non-zero tolerance when seeking to end"];
Expand All @@ -592,7 +649,9 @@ - (void)testSeekToleranceWhenSeekingToEnd {

/// Sanity checks a video player playing the given URL with the actual AVPlayer. This is essentially
/// a mini integration test of the player component.
- (NSDictionary<NSString *, id> *)sanityTestURI:(NSString *)testURI {
///
/// Returns the stub event listener to allow tests to inspect the call state.
- (StubEventListener *)sanityTestURI:(NSString *)testURI {
NSURL *testURL = [NSURL URLWithString:testURI];
XCTAssertNotNil(testURL);
FVPVideoPlayer *player =
Expand All @@ -603,15 +662,9 @@ - (void)testSeekToleranceWhenSeekingToEnd {
XCTAssertNotNil(player);

XCTestExpectation *initializedExpectation = [self expectationWithDescription:@"initialized"];
__block NSDictionary<NSString *, id> *initializationEvent;
[player onListenWithArguments:nil
eventSink:^(NSDictionary<NSString *, id> *event) {
if ([event[@"event"] isEqualToString:@"initialized"]) {
initializationEvent = event;
XCTAssertEqual(event.count, 4);
[initializedExpectation fulfill];
}
}];
StubEventListener *listener =
[[StubEventListener alloc] initWithInitalizationExpectation:initializedExpectation];
player.eventListener = listener;
[self waitForExpectationsWithTimeout:30.0 handler:nil];

// Starts paused.
Expand All @@ -634,9 +687,7 @@ - (void)testSeekToleranceWhenSeekingToEnd {
XCTAssertNil(error);
XCTAssertEqual(avPlayer.volume, 0.1f);

[player onCancelWithArguments:nil];

return initializationEvent;
return listener;
}

// Checks whether [AVPlayer rate] KVO observations are correctly detached.
Expand Down Expand Up @@ -787,12 +838,15 @@ - (void)testFailedToLoadVideoEventShouldBeAlwaysSent {
[self waitForExpectationsWithTimeout:10.0 handler:nil];

XCTestExpectation *failedExpectation = [self expectationWithDescription:@"failed"];
[player onListenWithArguments:nil
eventSink:^(FlutterError *event) {
if ([event isKindOfClass:FlutterError.class]) {
[failedExpectation fulfill];
}
}];
// TODO(stuartmorgan): Update this test to instead use a mock listener, and add separate unit
// tests of FVPEventBridge.
[(NSObject<FlutterStreamHandler> *)player.eventListener
onListenWithArguments:nil
eventSink:^(FlutterError *event) {
if ([event isKindOfClass:FlutterError.class]) {
[failedExpectation fulfill];
}
}];
[self waitForExpectationsWithTimeout:10.0 handler:nil];
}

Expand All @@ -804,12 +858,9 @@ - (void)testUpdatePlayingStateShouldNotResetRate {
viewProvider:[[StubViewProvider alloc] initWithView:nil]];

XCTestExpectation *initializedExpectation = [self expectationWithDescription:@"initialized"];
[player onListenWithArguments:nil
eventSink:^(NSDictionary<NSString *, id> *event) {
if ([event[@"event"] isEqualToString:@"initialized"]) {
[initializedExpectation fulfill];
}
}];
StubEventListener *listener =
[[StubEventListener alloc] initWithInitalizationExpectation:initializedExpectation];
player.eventListener = listener;
[self waitForExpectationsWithTimeout:10 handler:nil];

FlutterError *error;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "./include/video_player_avfoundation/FVPEventBridge.h"

#import <Foundation/Foundation.h>

#if TARGET_OS_OSX
#import <FlutterMacOS/FlutterMacOS.h>
#else
#import <Flutter/Flutter.h>
#endif

@interface FVPEventBridge () <FlutterStreamHandler>

/// The event channel to dispatch notifications to.
// TODO(stuartmorgan): Convert this to Pigeon event channels once the plugin is using Swift
// Pigeon generation.
@property(nonatomic) FlutterEventChannel *eventChannel;

/// The event sink associated with eventChannel.
///
/// Will be nil both before the channel listener is ready on the Dart side, and after it has been
/// closed on the Dart side.
@property(nonatomic, nullable) FlutterEventSink eventSink;

/// A queue of events received before eventSink is ready, to dispatch once the channel is fully
/// set up.
@property(nonatomic, copy) NSMutableArray<NSObject *> *queuedEvents;

@end

@implementation FVPEventBridge

- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger> *)messenger
channelName:(NSString *)channelName {
self = [super init];
if (self) {
_queuedEvents = [[NSMutableArray alloc] init];
_eventChannel = [FlutterEventChannel eventChannelWithName:channelName
binaryMessenger:messenger];
// This retain loop is broken in videoPlayerWasDisposed.
[_eventChannel setStreamHandler:self];
}
return self;
}

#pragma mark FlutterStreamHandler

- (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments
eventSink:(nonnull FlutterEventSink)events {
self.eventSink = events;

// Send any events that came in before the sink was ready.
for (id event in self.queuedEvents) {
self.eventSink(event);
}
[self.queuedEvents removeAllObjects];

return nil;
}

- (FlutterError *_Nullable)onCancelWithArguments:(id _Nullable)arguments {
Copy link
Contributor

Choose a reason for hiding this comment

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

The documentation says:

Such request pairs may occur during Flutter hot restart.

Are plugins destroyed and recreated during hot restart, or they live through hot restart? If it's the latter then the event queue is always nil after a hot restart?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The native code's plugin instance survives hot restart, but video_player calls an init method when the Dart plugin instance (which doesn't survive) is created, which will call this code and destroy all the player instances, and thus their bridges.

(Even if it didn't, since these channels have ID-based names, and the IDs are assigned by the native code, after a hot restart it should never be possible for the Dart code to try re-listening to any previously-existing video player event channels anyway.)

self.eventSink = nil;
// No need to queue events coming in after this point; nil the queue so they will be discarded.
self.queuedEvents = nil;
return nil;
}

#pragma mark FVPVideoPlayerDelegate

- (void)videoPlayerDidInitializeWithDuration:(int64_t)duration size:(CGSize)size {
[self sendOrQueue:@{
@"event" : @"initialized",
@"duration" : @(duration),
@"width" : @(size.width),
@"height" : @(size.height)
}];
}

- (void)videoPlayerDidErrorWithMessage:(NSString *)errorMessage {
[self sendOrQueue:[FlutterError errorWithCode:@"VideoError" message:errorMessage details:nil]];
}

- (void)videoPlayerDidComplete {
[self sendOrQueue:@{@"event" : @"completed"}];
}

- (void)videoPlayerDidStartBuffering {
[self sendOrQueue:@{@"event" : @"bufferingStart"}];
}

- (void)videoPlayerDidEndBuffering {
[self sendOrQueue:@{@"event" : @"bufferingEnd"}];
}

- (void)videoPlayerDidUpdateBufferRegions:(NSArray<NSArray<NSValue *> *> *)regions {
[self sendOrQueue:@{@"event" : @"bufferingUpdate", @"values" : regions}];
}

- (void)videoPlayerDidSetPlaying:(BOOL)playing {
[self sendOrQueue:@{@"event" : @"isPlayingStateUpdate", @"isPlaying" : @(playing)}];
}

- (void)videoPlayerWasDisposed {
[self.eventChannel setStreamHandler:nil];
}

#pragma mark Private methods

/// Sends the given event to the event sink if it is ready to receive events, or enqueues it to send
/// later if not.
- (void)sendOrQueue:(id)event {
if (self.eventSink) {
self.eventSink(event);
} else {
[self.queuedEvents addObject:event];
}
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ - (void)seekTo:(NSInteger)position completion:(void (^)(FlutterError *_Nullable)
}];
}

- (void)disposeSansEventChannel {
- (void)dispose {
// This check prevents the crash caused by removing the KVO observers twice.
// When performing a Hot Restart, the leftover players are disposed once directly
// by [FVPVideoPlayerPlugin initialize:] method and then disposed again by
Expand All @@ -127,7 +127,7 @@ - (void)disposeSansEventChannel {
return;
}

[super disposeSansEventChannel];
[super dispose];

[self.playerLayer removeFromSuperlayer];

Expand Down
Loading