Skip to content

Commit 7063d75

Browse files
[video_player_avfoundation] removes unnecessary duration and size check before sending the initialized event (#9534)
Fixes flutter/flutter#166914. The duration check was first introduced [here](flutter/plugins#1554) but zero division should probably be handled by the client not the plugin. Also I suspect that flutter/flutter#91975 wasn't caused by an AVFoundation bug but rather a bug in `video_player_avfoundation`: The `_isInitialized` flag was roughly equivalent to `.readyToPlay` [a long time ago](https://github.com/flutter/plugins/pull/280/files#diff-f4c7ef029bb5e0be8a6851b6b022c745dc4e99e3739f1c2f7f4944b85bc154a6R174-R178). The flag was later re-purposed to indicate whether an `initialized` message has already been sent (to make sure the message is only sent once), and [that PR](flutter/plugins#1552) also introduced a size check to determine whether the media is loaded (which doesn't seem to be correct, `.readyToPlay` is the public API for this, see Apple's [AVPlayerDemo](https://developer.apple.com/library/archive/samplecode/AVPlayerDemo/Listings/Classes_AVPlayerDemoPlaybackViewController_m.html#//apple_ref/doc/uid/DTS40010101-Classes_AVPlayerDemoPlaybackViewController_m-DontLinkElementID_8)). But that PR didn't update the `onListenWithArguments` implementation so the plugin started sending `initialized` events on first subscription even the `AVPlayerItem` isn't ready to play. So that looks like a bug in the plugin not in AVFoundation and that allows us to remove a bunch of workarounds. Couldn't reproduce flutter/flutter#91975 on iOS16 with this patch (I can't install iOS15 sim runtime, it fails silently, but on iOS16 it still hits the size + duration check callpath so I think not much has changed from iOS15 to iOS16 as far as AVFoundation stuff is concerned). I didn't try to reproduce flutter/flutter#19197 with this patch since the issue doesn't have a minimal repro. I did not change the `_isInitialized` semantic so I assume the patch won't regress that fix. More on `isInitialized`: The player implementation doesn't have to rely on this property, if `AVPlayerItem.Status` can not recover from `.failed` to `.readyToPlay` (i.e. it can only change to `.readyToPlay` once). But the documentation doesn't say if that is the case. ## Pre-Review Checklist [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent 71e05e1 commit 7063d75

File tree

11 files changed

+206
-143
lines changed

11 files changed

+206
-143
lines changed

packages/video_player/video_player/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
## NEXT
1+
## 2.10.1
22

3+
* Fixes a bug where the `VideoPlayer` widget and `VideoProgressIndicator` widget would stop updating after GlobalKey reparenting.
4+
* Updates the `VideoProgressIndicator` widget to handle zero-duration videos.
35
* Updates minimum supported SDK version to Flutter 3.29/Dart 3.7.
46

57
## 2.10.0

packages/video_player/video_player/lib/video_player.dart

Lines changed: 37 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'dart:async';
66
import 'dart:io';
7+
import 'dart:math' as math show max;
78

89
import 'package:flutter/foundation.dart';
910
import 'package:flutter/material.dart';
@@ -79,7 +80,7 @@ class VideoPlayerValue {
7980

8081
/// The total duration of the video.
8182
///
82-
/// The duration is [Duration.zero] if the video hasn't been initialized.
83+
/// The value is only meaningful when [isInitialized] is true.
8384
final Duration duration;
8485

8586
/// The current playback position.
@@ -430,7 +431,7 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
430431
_lifeCycleObserver?.initialize();
431432
_creatingCompleter = Completer<void>();
432433

433-
late DataSource dataSourceDescription;
434+
final DataSource dataSourceDescription;
434435
switch (dataSourceType) {
435436
case DataSourceType.asset:
436437
dataSourceDescription = DataSource(
@@ -863,42 +864,37 @@ class VideoPlayer extends StatefulWidget {
863864
}
864865

865866
class _VideoPlayerState extends State<VideoPlayer> {
866-
_VideoPlayerState() {
867-
_listener = () {
868-
final int newPlayerId = widget.controller.playerId;
869-
if (newPlayerId != _playerId) {
870-
setState(() {
871-
_playerId = newPlayerId;
872-
});
873-
}
874-
};
875-
}
876-
877-
late VoidCallback _listener;
878-
879867
late int _playerId;
868+
void _controllerDidUpdateValue() {
869+
final int newPlayerId = widget.controller.playerId;
870+
if (newPlayerId != _playerId) {
871+
setState(() {
872+
_playerId = newPlayerId;
873+
});
874+
}
875+
}
880876

881877
@override
882878
void initState() {
883879
super.initState();
884880
_playerId = widget.controller.playerId;
885881
// Need to listen for initialization events since the actual widget ID
886882
// becomes available after asynchronous initialization finishes.
887-
widget.controller.addListener(_listener);
883+
widget.controller.addListener(_controllerDidUpdateValue);
888884
}
889885

890886
@override
891887
void didUpdateWidget(VideoPlayer oldWidget) {
892888
super.didUpdateWidget(oldWidget);
893-
oldWidget.controller.removeListener(_listener);
889+
oldWidget.controller.removeListener(_controllerDidUpdateValue);
894890
_playerId = widget.controller.playerId;
895-
widget.controller.addListener(_listener);
891+
widget.controller.addListener(_controllerDidUpdateValue);
896892
}
897893

898894
@override
899-
void deactivate() {
900-
super.deactivate();
901-
widget.controller.removeListener(_listener);
895+
void dispose() {
896+
widget.controller.removeListener(_controllerDidUpdateValue);
897+
super.dispose();
902898
}
903899

904900
@override
@@ -1089,58 +1085,52 @@ class VideoProgressIndicator extends StatefulWidget {
10891085
}
10901086

10911087
class _VideoProgressIndicatorState extends State<VideoProgressIndicator> {
1092-
_VideoProgressIndicatorState() {
1093-
listener = () {
1094-
if (!mounted) {
1095-
return;
1096-
}
1097-
setState(() {});
1098-
};
1099-
}
1100-
1101-
late VoidCallback listener;
1102-
11031088
VideoPlayerController get controller => widget.controller;
11041089

11051090
VideoProgressColors get colors => widget.colors;
11061091

1092+
void _didUpdateControllerValue() {
1093+
setState(() {
1094+
// The build method reads from controller.value.
1095+
});
1096+
}
1097+
11071098
@override
11081099
void initState() {
11091100
super.initState();
1110-
controller.addListener(listener);
1101+
controller.addListener(_didUpdateControllerValue);
11111102
}
11121103

11131104
@override
1114-
void deactivate() {
1115-
controller.removeListener(listener);
1116-
super.deactivate();
1105+
void dispose() {
1106+
controller.removeListener(_didUpdateControllerValue);
1107+
super.dispose();
11171108
}
11181109

11191110
@override
11201111
Widget build(BuildContext context) {
1121-
Widget progressIndicator;
1112+
final Widget progressIndicator;
11221113
if (controller.value.isInitialized) {
11231114
final int duration = controller.value.duration.inMilliseconds;
11241115
final int position = controller.value.position.inMilliseconds;
11251116

1126-
int maxBuffering = 0;
1127-
for (final DurationRange range in controller.value.buffered) {
1128-
final int end = range.end.inMilliseconds;
1129-
if (end > maxBuffering) {
1130-
maxBuffering = end;
1131-
}
1132-
}
1133-
1117+
final double maxBuffering =
1118+
duration == 0.0
1119+
? 0.0
1120+
: controller.value.buffered
1121+
.map((DurationRange range) => range.end.inMilliseconds)
1122+
.fold(0, math.max) /
1123+
duration;
11341124
progressIndicator = Stack(
11351125
fit: StackFit.passthrough,
11361126
children: <Widget>[
11371127
LinearProgressIndicator(
1138-
value: maxBuffering / duration,
1128+
value: maxBuffering,
11391129
valueColor: AlwaysStoppedAnimation<Color>(colors.bufferedColor),
11401130
backgroundColor: colors.backgroundColor,
11411131
),
11421132
LinearProgressIndicator(
1143-
value: position / duration,
1133+
value: duration == 0.0 ? 0.0 : position / duration,
11441134
valueColor: AlwaysStoppedAnimation<Color>(colors.playedColor),
11451135
backgroundColor: Colors.transparent,
11461136
),

packages/video_player/video_player/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter
33
widgets on Android, iOS, macOS and web.
44
repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player
55
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22
6-
version: 2.10.0
6+
version: 2.10.1
77

88
environment:
99
sdk: ^3.7.0

packages/video_player/video_player/test/video_player_test.dart

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,87 @@ void main() {
172172
);
173173
});
174174

175+
testWidgets(
176+
'VideoPlayer still listens for controller changes when reparented',
177+
(WidgetTester tester) async {
178+
final FakeController controller = FakeController();
179+
addTearDown(controller.dispose);
180+
final GlobalKey videoKey = GlobalKey();
181+
final Widget videoPlayer = KeyedSubtree(
182+
key: videoKey,
183+
child: VideoPlayer(controller),
184+
);
185+
186+
await tester.pumpWidget(videoPlayer);
187+
expect(find.byType(Texture), findsNothing);
188+
189+
// The VideoPlayer is reparented in the widget tree, before the
190+
// underlying player is initialized.
191+
await tester.pumpWidget(SizedBox(child: videoPlayer));
192+
controller.playerId = 321;
193+
controller.value = controller.value.copyWith(
194+
duration: const Duration(milliseconds: 100),
195+
isInitialized: true,
196+
);
197+
198+
await tester.pump();
199+
expect(
200+
find.byWidgetPredicate(
201+
(Widget widget) => widget is Texture && widget.textureId == 321,
202+
),
203+
findsOneWidget,
204+
);
205+
},
206+
);
207+
208+
testWidgets(
209+
'VideoProgressIndicator still listens for controller changes after reparenting',
210+
(WidgetTester tester) async {
211+
final FakeController controller = FakeController();
212+
addTearDown(controller.dispose);
213+
final GlobalKey key = GlobalKey();
214+
final Widget progressIndicator = VideoProgressIndicator(
215+
key: key,
216+
controller,
217+
allowScrubbing: false,
218+
);
219+
220+
controller.value = controller.value.copyWith(
221+
duration: const Duration(milliseconds: 100),
222+
position: const Duration(milliseconds: 50),
223+
isInitialized: true,
224+
);
225+
await tester.pumpWidget(MaterialApp(home: progressIndicator));
226+
await tester.pump();
227+
await tester.pumpWidget(
228+
MaterialApp(home: SizedBox(child: progressIndicator)),
229+
);
230+
expect((key.currentContext! as Element).dirty, isFalse);
231+
// Verify that changing value dirties the widget tree.
232+
controller.value = controller.value.copyWith(
233+
position: const Duration(milliseconds: 100),
234+
);
235+
expect((key.currentContext! as Element).dirty, isTrue);
236+
},
237+
);
238+
239+
testWidgets('VideoPlayer does not crash after loading 0-duration videos', (
240+
WidgetTester tester,
241+
) async {
242+
final FakeController controller = FakeController();
243+
addTearDown(controller.dispose);
244+
controller.value = controller.value.copyWith(
245+
duration: Duration.zero,
246+
isInitialized: true,
247+
);
248+
await tester.pumpWidget(
249+
MaterialApp(
250+
home: VideoProgressIndicator(controller, allowScrubbing: false),
251+
),
252+
);
253+
expect(tester.takeException(), isNull);
254+
});
255+
175256
testWidgets('non-zero rotationCorrection value is used', (
176257
WidgetTester tester,
177258
) async {

packages/video_player/video_player_android/example/lib/mini_controller.dart

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import 'dart:async';
99
import 'dart:io';
10+
import 'dart:math' as math show max;
1011

1112
import 'package:flutter/foundation.dart';
1213
import 'package:flutter/material.dart';
@@ -433,9 +434,9 @@ class _VideoPlayerState extends State<VideoPlayer> {
433434
}
434435

435436
@override
436-
void deactivate() {
437-
super.deactivate();
437+
void dispose() {
438438
widget.controller.removeListener(_listener);
439+
super.dispose();
439440
}
440441

441442
@override
@@ -535,9 +536,9 @@ class _VideoProgressIndicatorState extends State<VideoProgressIndicator> {
535536
}
536537

537538
@override
538-
void deactivate() {
539+
void dispose() {
539540
controller.removeListener(listener);
540-
super.deactivate();
541+
super.dispose();
541542
}
542543

543544
@override
@@ -546,29 +547,28 @@ class _VideoProgressIndicatorState extends State<VideoProgressIndicator> {
546547
const Color bufferedColor = Color.fromRGBO(50, 50, 200, 0.2);
547548
const Color backgroundColor = Color.fromRGBO(200, 200, 200, 0.5);
548549

549-
Widget progressIndicator;
550+
final Widget progressIndicator;
550551
if (controller.value.isInitialized) {
551552
final int duration = controller.value.duration.inMilliseconds;
552553
final int position = controller.value.position.inMilliseconds;
553554

554-
int maxBuffering = 0;
555-
for (final DurationRange range in controller.value.buffered) {
556-
final int end = range.end.inMilliseconds;
557-
if (end > maxBuffering) {
558-
maxBuffering = end;
559-
}
560-
}
555+
final double maxBuffering = duration == 0.0
556+
? 0.0
557+
: controller.value.buffered
558+
.map((DurationRange range) => range.end.inMilliseconds)
559+
.fold(0, math.max) /
560+
duration;
561561

562562
progressIndicator = Stack(
563563
fit: StackFit.passthrough,
564564
children: <Widget>[
565565
LinearProgressIndicator(
566-
value: maxBuffering / duration,
566+
value: maxBuffering,
567567
valueColor: const AlwaysStoppedAnimation<Color>(bufferedColor),
568568
backgroundColor: backgroundColor,
569569
),
570570
LinearProgressIndicator(
571-
value: position / duration,
571+
value: duration == 0.0 ? 0.0 : position / duration,
572572
valueColor: const AlwaysStoppedAnimation<Color>(playedColor),
573573
backgroundColor: Colors.transparent,
574574
),

packages/video_player/video_player_avfoundation/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 2.8.6
2+
3+
* Fixes a bug where the video player fails to initialize when `AVFoundation` reports a duration of zero.
4+
* Fixes a bug in the example app that some widgets stop updating after GlobalKey reparenting.
5+
* Updates the `VideoProgressIndicator` widget in the example app to handle zero-duration videos.
6+
17
## 2.8.5
28

39
* Updates minimum supported version to iOS 13 and macOS 10.15.

packages/video_player/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.m

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,33 @@ - (void)testPlayerShouldNotDropEverySecondFrame {
936936
OCMVerifyAllWithDelay(mockTextureRegistry, 10);
937937
}
938938

939+
- (void)testVideoOutputIsAddedWhenAVPlayerItemBecomesReady {
940+
NSObject<FlutterPluginRegistrar> *registrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar));
941+
FVPVideoPlayerPlugin *videoPlayerPlugin =
942+
[[FVPVideoPlayerPlugin alloc] initWithRegistrar:registrar];
943+
FlutterError *error;
944+
[videoPlayerPlugin initialize:&error];
945+
XCTAssertNil(error);
946+
FVPCreationOptions *create = [FVPCreationOptions
947+
makeWithUri:@"https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4"
948+
httpHeaders:@{}];
949+
950+
FVPTexturePlayerIds *identifiers = [videoPlayerPlugin createTexturePlayerWithOptions:create
951+
error:&error];
952+
XCTAssertNil(error);
953+
XCTAssertNotNil(identifiers);
954+
FVPVideoPlayer *player = videoPlayerPlugin.playersByIdentifier[@(identifiers.playerId)];
955+
XCTAssertNotNil(player);
956+
957+
AVPlayerItem *item = player.player.currentItem;
958+
[self keyValueObservingExpectationForObject:(id)item
959+
keyPath:@"status"
960+
expectedValue:@(AVPlayerItemStatusReadyToPlay)];
961+
[self waitForExpectationsWithTimeout:10.0 handler:nil];
962+
// Video output is added as soon as the status becomes ready to play.
963+
XCTAssertEqual(item.outputs.count, 1);
964+
}
965+
939966
#if TARGET_OS_IOS
940967
- (void)testVideoPlayerShouldNotOverwritePlayAndRecordNorDefaultToSpeaker {
941968
NSObject<FlutterPluginRegistrar> *registrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar));

0 commit comments

Comments
 (0)