diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 3524be91edcd..e6370aff6fb6 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,4 +1,8 @@ -## NEXT +## 2.10.3 + +* Optimizes caption retrieval with binary search. + +## 2.10.2 * Updates minimum supported SDK version to Flutter 3.32/Dart 3.8. * Updates README to reflect currently supported OS versions for the latest diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index f0589cb46869..29593d263247 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math show max; +import 'package:collection/collection.dart' as collection; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -404,6 +405,8 @@ class VideoPlayerController extends ValueNotifier { Future? _closedCaptionFileFuture; ClosedCaptionFile? _closedCaptionFile; + List? _sortedCaptions; + Timer? _timer; bool _isDisposed = false; Completer? _creatingCompleter; @@ -758,20 +761,41 @@ class VideoPlayerController extends ValueNotifier { /// /// If no [closedCaptionFile] was specified, this will always return an empty /// [Caption]. + Caption _getCaptionAt(Duration position) { - if (_closedCaptionFile == null) { + final List? sortedCaptions = _sortedCaptions; + if (_closedCaptionFile == null || sortedCaptions == null) { return Caption.none; } final Duration delayedPosition = position + value.captionOffset; - // TODO(johnsonmh): This would be more efficient as a binary search. - for (final Caption caption in _closedCaptionFile!.captions) { - if (caption.start <= delayedPosition && caption.end >= delayedPosition) { - return caption; - } + + final int captionIndex = collection.binarySearch( + sortedCaptions, + Caption( + number: -1, + start: delayedPosition, + end: delayedPosition, + text: '', + ), + compare: (Caption candidate, Caption search) { + if (search.start < candidate.start) { + return 1; + } else if (search.start > candidate.end) { + return -1; + } else { + // delayedPosition is within [candidate.start, candidate.end] + return 0; + } + }, + ); + + // -1 means not found by the binary search. + if (captionIndex == -1) { + return Caption.none; } - return Caption.none; + return sortedCaptions[captionIndex]; } /// Returns the file containing closed captions for the video, if any. @@ -785,15 +809,30 @@ class VideoPlayerController extends ValueNotifier { Future setClosedCaptionFile( Future? closedCaptionFile, ) async { - await _updateClosedCaptionWithFuture(closedCaptionFile); _closedCaptionFileFuture = closedCaptionFile; + // Reset sorted captions to force re-sort when setting a new file + _sortedCaptions = null; + await _updateClosedCaptionWithFuture(closedCaptionFile); } Future _updateClosedCaptionWithFuture( Future? closedCaptionFile, ) async { - _closedCaptionFile = await closedCaptionFile; - value = value.copyWith(caption: _getCaptionAt(value.position)); + if (closedCaptionFile != null) { + _closedCaptionFile = await closedCaptionFile; + + // Only sort if we haven't sorted yet (first initialization) + _sortedCaptions ??= List.from(_closedCaptionFile!.captions) + ..sort((Caption a, Caption b) { + return a.start.compareTo(b.start); + }); + + value = value.copyWith(caption: _getCaptionAt(value.position)); + } else { + _closedCaptionFile = null; + _sortedCaptions = null; + value = value.copyWith(caption: Caption.none); + } } void _updatePosition(Duration position) { diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index a71e45323394..6db3e51989f0 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, macOS and web. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.10.1 +version: 2.10.2 environment: sdk: ^3.8.0 @@ -22,6 +22,7 @@ flutter: default_package: video_player_web dependencies: + collection: ^1.18.0 flutter: sdk: flutter html: ^0.15.0 diff --git a/packages/video_player/video_player/test/video_player_test.dart b/packages/video_player/video_player/test/video_player_test.dart index ea565bd90730..8c0b2ec8d9fa 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -99,12 +99,68 @@ class _FakeClosedCaptionFile extends ClosedCaptionFile { start: Duration(milliseconds: 100), end: Duration(milliseconds: 200), ), + const Caption( text: 'two', number: 1, start: Duration(milliseconds: 300), end: Duration(milliseconds: 400), ), + + /// out of order subs to test sorting + const Caption( + text: 'three', + number: 1, + start: Duration(milliseconds: 500), + end: Duration(milliseconds: 600), + ), + + const Caption( + text: 'five', + number: 0, + start: Duration(milliseconds: 700), + end: Duration(milliseconds: 800), + ), + const Caption( + text: 'four', + number: 0, + start: Duration(milliseconds: 600), + end: Duration(milliseconds: 700), + ), + ]; + } +} + +class _SingleCaptionFile extends ClosedCaptionFile { + @override + List get captions { + return [ + const Caption( + text: 'only', + number: 0, + start: Duration(milliseconds: 100), + end: Duration(milliseconds: 200), + ), + ]; + } +} + +class _OverlappingCaptionFile extends ClosedCaptionFile { + @override + List get captions { + return [ + const Caption( + text: 'first', + number: 0, + start: Duration(milliseconds: 100), + end: Duration(milliseconds: 300), + ), + const Caption( + text: 'second', + number: 1, + start: Duration(milliseconds: 200), + end: Duration(milliseconds: 400), + ), ]; } } @@ -858,7 +914,35 @@ void main() { expect(recordedCaptions[300], 'two'); }); - test('works when seeking', () async { + test('makes sure the input captions are unsorted', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + closedCaptionFile: _loadClosedCaption(), + ); + + await controller.initialize(); + final List captions = (await controller.closedCaptionFile)! + .captions + .toList(); + + // Check that captions are not in sorted order. + var isSorted = true; + for (var i = 0; i < captions.length - 1; i++) { + if (captions[i].start.compareTo(captions[i + 1].start) > 0) { + isSorted = false; + break; + } + } + + expect(isSorted, false, reason: 'Expected captions to be unsorted'); + expect( + captions.map((Caption c) => c.text).toList(), + ['one', 'two', 'three', 'five', 'four'], + reason: 'Captions should be in original unsorted order', + ); + }); + + test('works when seeking, includes all captions', () async { final controller = VideoPlayerController.networkUrl( _localhostUri, closedCaptionFile: _loadClosedCaption(), @@ -881,17 +965,138 @@ void main() { await controller.seekTo(const Duration(milliseconds: 301)); expect(controller.value.caption.text, 'two'); + await controller.seekTo(const Duration(milliseconds: 400)); + expect(controller.value.caption.text, 'two'); + + await controller.seekTo(const Duration(milliseconds: 401)); + expect(controller.value.caption.text, ''); + await controller.seekTo(const Duration(milliseconds: 500)); + expect(controller.value.caption.text, 'three'); + + await controller.seekTo(const Duration(milliseconds: 601)); + expect(controller.value.caption.text, 'four'); + + await controller.seekTo(const Duration(milliseconds: 701)); + expect(controller.value.caption.text, 'five'); + + await controller.seekTo(const Duration(milliseconds: 800)); + expect(controller.value.caption.text, 'five'); + await controller.seekTo(const Duration(milliseconds: 801)); expect(controller.value.caption.text, ''); + // Test going back await controller.seekTo(const Duration(milliseconds: 300)); expect(controller.value.caption.text, 'two'); + }); - await controller.seekTo(const Duration(milliseconds: 301)); - expect(controller.value.caption.text, 'two'); + test( + 'works when seeking with captionOffset positive, includes all captions', + () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + closedCaptionFile: _loadClosedCaption(), + ); + addTearDown(controller.dispose); + + await controller.initialize(); + controller.setCaptionOffset(const Duration(milliseconds: 100)); + expect(controller.value.position, Duration.zero); + expect(controller.value.caption.text, ''); + + await controller.seekTo(const Duration(milliseconds: 99)); + expect(controller.value.caption.text, 'one'); + + await controller.seekTo(const Duration(milliseconds: 100)); + expect(controller.value.caption.text, 'one'); + + await controller.seekTo(const Duration(milliseconds: 101)); + expect(controller.value.caption.text, ''); + + await controller.seekTo(const Duration(milliseconds: 150)); + expect(controller.value.caption.text, ''); + + await controller.seekTo(const Duration(milliseconds: 200)); + expect(controller.value.caption.text, 'two'); + + await controller.seekTo(const Duration(milliseconds: 201)); + expect(controller.value.caption.text, 'two'); + + await controller.seekTo(const Duration(milliseconds: 400)); + expect(controller.value.caption.text, 'three'); + + await controller.seekTo(const Duration(milliseconds: 500)); + expect(controller.value.caption.text, 'three'); + + await controller.seekTo(const Duration(milliseconds: 600)); + expect(controller.value.caption.text, 'five'); + + await controller.seekTo(const Duration(milliseconds: 700)); + expect(controller.value.caption.text, 'five'); + + await controller.seekTo(const Duration(milliseconds: 800)); + expect(controller.value.caption.text, ''); + }, + ); + + test( + 'works when seeking with captionOffset negative, includes all captions', + () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + closedCaptionFile: _loadClosedCaption(), + ); + addTearDown(controller.dispose); + + await controller.initialize(); + controller.setCaptionOffset(const Duration(milliseconds: -100)); + expect(controller.value.position, Duration.zero); + expect(controller.value.caption.text, ''); + + await controller.seekTo(const Duration(milliseconds: 100)); + expect(controller.value.caption.text, ''); + + await controller.seekTo(const Duration(milliseconds: 200)); + expect(controller.value.caption.text, 'one'); + + await controller.seekTo(const Duration(milliseconds: 250)); + expect(controller.value.caption.text, 'one'); + + await controller.seekTo(const Duration(milliseconds: 300)); + expect(controller.value.caption.text, 'one'); + + await controller.seekTo(const Duration(milliseconds: 301)); + expect(controller.value.caption.text, ''); + + await controller.seekTo(const Duration(milliseconds: 400)); + expect(controller.value.caption.text, 'two'); + + await controller.seekTo(const Duration(milliseconds: 500)); + expect(controller.value.caption.text, 'two'); + + await controller.seekTo(const Duration(milliseconds: 600)); + expect(controller.value.caption.text, 'three'); + + await controller.seekTo(const Duration(milliseconds: 700)); + expect(controller.value.caption.text, 'three'); + }, + ); + + test('setClosedCaptionFile loads caption file', () async { + final controller = VideoPlayerController.networkUrl(_localhostUri); + addTearDown(controller.dispose); + + await controller.initialize(); + expect(controller.closedCaptionFile, null); + + await controller.setClosedCaptionFile(_loadClosedCaption()); + expect( + (await controller.closedCaptionFile)!.captions, + (await _loadClosedCaption()).captions, + ); }); - test('works when seeking with captionOffset positive', () async { + test('setClosedCaptionFile removes/changes caption file', () async { final controller = VideoPlayerController.networkUrl( _localhostUri, closedCaptionFile: _loadClosedCaption(), @@ -899,36 +1104,64 @@ void main() { addTearDown(controller.dispose); await controller.initialize(); - controller.setCaptionOffset(const Duration(milliseconds: 100)); - expect(controller.value.position, Duration.zero); - expect(controller.value.caption.text, ''); + expect( + (await controller.closedCaptionFile)!.captions, + (await _loadClosedCaption()).captions, + ); - await controller.seekTo(const Duration(milliseconds: 100)); - expect(controller.value.caption.text, 'one'); + await controller.setClosedCaptionFile(null); + expect(controller.closedCaptionFile, null); + }); - await controller.seekTo(const Duration(milliseconds: 101)); - expect(controller.value.caption.text, ''); + test('binary search handles exact caption start time boundary', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + closedCaptionFile: _loadClosedCaption(), + ); + addTearDown(controller.dispose); - await controller.seekTo(const Duration(milliseconds: 250)); - expect(controller.value.caption.text, 'two'); + await controller.initialize(); - await controller.seekTo(const Duration(milliseconds: 300)); - expect(controller.value.caption.text, 'two'); + // Seek to exact start times - should find the caption + await controller.seekTo(const Duration(milliseconds: 100)); + expect( + controller.value.caption.text, + 'one', + reason: 'Should find caption at exact start time (100ms)', + ); - await controller.seekTo(const Duration(milliseconds: 301)); - expect(controller.value.caption.text, ''); + await controller.seekTo(const Duration(milliseconds: 300)); + expect( + controller.value.caption.text, + 'two', + reason: 'Should find caption at exact start time (300ms)', + ); await controller.seekTo(const Duration(milliseconds: 500)); - expect(controller.value.caption.text, ''); + expect( + controller.value.caption.text, + 'three', + reason: 'Should find caption at exact start time (500ms)', + ); - await controller.seekTo(const Duration(milliseconds: 300)); - expect(controller.value.caption.text, 'two'); + // At 600ms, "three" ends and "four" starts - binary search may find either + await controller.seekTo(const Duration(milliseconds: 600)); + expect( + ['three', 'four'].contains(controller.value.caption.text), + true, + reason: + 'Should find a caption at boundary (600ms) where two captions meet (got "${controller.value.caption.text}")', + ); - await controller.seekTo(const Duration(milliseconds: 301)); - expect(controller.value.caption.text, ''); + await controller.seekTo(const Duration(milliseconds: 700)); + expect( + controller.value.caption.text, + 'five', + reason: 'Should find caption at exact start time (700ms)', + ); }); - test('works when seeking with captionOffset negative', () async { + test('binary search handles exact caption end time boundary', () async { final controller = VideoPlayerController.networkUrl( _localhostUri, closedCaptionFile: _loadClosedCaption(), @@ -936,67 +1169,211 @@ void main() { addTearDown(controller.dispose); await controller.initialize(); - controller.setCaptionOffset(const Duration(milliseconds: -100)); - expect(controller.value.position, Duration.zero); - expect(controller.value.caption.text, ''); - - await controller.seekTo(const Duration(milliseconds: 100)); - expect(controller.value.caption.text, ''); + // Seek to exact end times - should still find the caption await controller.seekTo(const Duration(milliseconds: 200)); - expect(controller.value.caption.text, 'one'); + expect( + controller.value.caption.text, + 'one', + reason: 'Should find caption at exact end time (200ms)', + ); - await controller.seekTo(const Duration(milliseconds: 250)); - expect(controller.value.caption.text, 'one'); + await controller.seekTo(const Duration(milliseconds: 400)); + expect( + controller.value.caption.text, + 'two', + reason: 'Should find caption at exact end time (400ms)', + ); - await controller.seekTo(const Duration(milliseconds: 300)); - expect(controller.value.caption.text, 'one'); + // At 600ms boundary where "three" ends and "four" starts + await controller.seekTo(const Duration(milliseconds: 600)); + expect( + ['three', 'four'].contains(controller.value.caption.text), + true, + reason: + 'Should find a caption at boundary (600ms) (got "${controller.value.caption.text}")', + ); - await controller.seekTo(const Duration(milliseconds: 301)); - expect(controller.value.caption.text, ''); + // At 700ms boundary where "four" ends and "five" starts + await controller.seekTo(const Duration(milliseconds: 700)); + expect( + ['four', 'five'].contains(controller.value.caption.text), + true, + reason: + 'Should find a caption at boundary (700ms) (got "${controller.value.caption.text}")', + ); - await controller.seekTo(const Duration(milliseconds: 400)); - expect(controller.value.caption.text, 'two'); + await controller.seekTo(const Duration(milliseconds: 800)); + expect( + controller.value.caption.text, + 'five', + reason: 'Should find caption at exact end time (800ms)', + ); - await controller.seekTo(const Duration(milliseconds: 500)); - expect(controller.value.caption.text, 'two'); + // One millisecond past the end should not find the caption + await controller.seekTo(const Duration(milliseconds: 201)); + expect( + controller.value.caption.text, + '', + reason: + 'Should not find caption one millisecond past end time (201ms)', + ); - await controller.seekTo(const Duration(milliseconds: 600)); - expect(controller.value.caption.text, ''); + await controller.seekTo(const Duration(milliseconds: 801)); + expect( + controller.value.caption.text, + '', + reason: + 'Should not find caption one millisecond past end time (801ms)', + ); + }); - await controller.seekTo(const Duration(milliseconds: 300)); - expect(controller.value.caption.text, 'one'); + test('binary search handles gaps between captions', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + closedCaptionFile: _loadClosedCaption(), + ); + addTearDown(controller.dispose); + + await controller.initialize(); + + // Test gaps between captions where no caption should be found + // Gap before first caption + await controller.seekTo(Duration.zero); + expect( + controller.value.caption.text, + '', + reason: 'Should return empty for position before first caption', + ); + + await controller.seekTo(const Duration(milliseconds: 99)); + expect( + controller.value.caption.text, + '', + reason: 'Should return empty for position before first caption', + ); + + // Gap between caption 1 (ends at 200) and caption 2 (starts at 300) + await controller.seekTo(const Duration(milliseconds: 250)); + expect( + controller.value.caption.text, + '', + reason: 'Should return empty for gap between captions 1 and 2', + ); + + // Gap between caption 2 (ends at 400) and caption 3 (starts at 500) + await controller.seekTo(const Duration(milliseconds: 450)); + expect( + controller.value.caption.text, + '', + reason: 'Should return empty for gap between captions 2 and 3', + ); + + // Gap after last caption + await controller.seekTo(const Duration(milliseconds: 900)); + expect( + controller.value.caption.text, + '', + reason: 'Should return empty for position after last caption', + ); }); - test('setClosedCaptionFile loads caption file', () async { - final controller = VideoPlayerController.networkUrl(_localhostUri); + test('binary search works with single caption', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + closedCaptionFile: Future.value( + _SingleCaptionFile(), + ), + ); addTearDown(controller.dispose); await controller.initialize(); - expect(controller.closedCaptionFile, null); - await controller.setClosedCaptionFile(_loadClosedCaption()); + // Before caption + await controller.seekTo(const Duration(milliseconds: 99)); expect( - (await controller.closedCaptionFile)!.captions, - (await _loadClosedCaption()).captions, + controller.value.caption.text, + '', + reason: 'Should return empty before single caption', + ); + + // At start + await controller.seekTo(const Duration(milliseconds: 100)); + expect( + controller.value.caption.text, + 'only', + reason: 'Should find single caption at start', + ); + + // In middle + await controller.seekTo(const Duration(milliseconds: 150)); + expect( + controller.value.caption.text, + 'only', + reason: 'Should find single caption in middle', + ); + + // At end + await controller.seekTo(const Duration(milliseconds: 200)); + expect( + controller.value.caption.text, + 'only', + reason: 'Should find single caption at end', + ); + + // After caption + await controller.seekTo(const Duration(milliseconds: 201)); + expect( + controller.value.caption.text, + '', + reason: 'Should return empty after single caption', ); }); - test('setClosedCaptionFile removes/changes caption file', () async { + test('binary search handles overlapping captions', () async { final controller = VideoPlayerController.networkUrl( _localhostUri, - closedCaptionFile: _loadClosedCaption(), + closedCaptionFile: Future.value( + _OverlappingCaptionFile(), + ), ); addTearDown(controller.dispose); await controller.initialize(); + + // In first caption only + await controller.seekTo(const Duration(milliseconds: 100)); expect( - (await controller.closedCaptionFile)!.captions, - (await _loadClosedCaption()).captions, + controller.value.caption.text, + 'first', + reason: 'Should find first caption', ); - await controller.setClosedCaptionFile(null); - expect(controller.closedCaptionFile, null); + // In overlapping region - binary search should find one of them + // (the exact one depends on sort order, but it should find something) + await controller.seekTo(const Duration(milliseconds: 250)); + expect( + ['first', 'second'].contains(controller.value.caption.text), + true, + reason: + 'Should find a caption in overlapping region (got "${controller.value.caption.text}")', + ); + + // In second caption only + await controller.seekTo(const Duration(milliseconds: 350)); + expect( + controller.value.caption.text, + 'second', + reason: 'Should find second caption', + ); + + // After all captions + await controller.seekTo(const Duration(milliseconds: 401)); + expect( + controller.value.caption.text, + '', + reason: 'Should return empty after all captions', + ); }); }); @@ -1089,271 +1466,310 @@ void main() { await tester.runAsync(controller.dispose); }); }); - }); - test('updates position', () async { - final controller = VideoPlayerController.networkUrl( - _localhostUri, - videoPlayerOptions: VideoPlayerOptions(), - ); + test('updates position', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(), + ); - await controller.initialize(); + await controller.initialize(); - const updatesInterval = Duration(milliseconds: 100); + const updatesInterval = Duration(milliseconds: 100); - final positions = []; - final intervalUpdateCompleter = Completer(); + final positions = []; + final intervalUpdateCompleter = Completer(); - // Listen for position updates - controller.addListener(() { - positions.add(controller.value.position); - if (positions.length >= 3 && !intervalUpdateCompleter.isCompleted) { - intervalUpdateCompleter.complete(); + // Listen for position updates + controller.addListener(() { + positions.add(controller.value.position); + if (positions.length >= 3 && !intervalUpdateCompleter.isCompleted) { + intervalUpdateCompleter.complete(); + } + }); + await controller.play(); + for (var i = 0; i < 3; i++) { + await Future.delayed(updatesInterval); + fakeVideoPlayerPlatform._positions[controller.playerId] = Duration( + milliseconds: i * updatesInterval.inMilliseconds, + ); } - }); - await controller.play(); - for (var i = 0; i < 3; i++) { - await Future.delayed(updatesInterval); - fakeVideoPlayerPlatform._positions[controller.playerId] = Duration( - milliseconds: i * updatesInterval.inMilliseconds, + + // Wait for at least 3 position updates + await intervalUpdateCompleter.future; + + // Verify that the intervals between updates are approximately correct + expect( + positions[1] - positions[0], + greaterThanOrEqualTo(updatesInterval), ); - } + expect( + positions[2] - positions[1], + greaterThanOrEqualTo(updatesInterval), + ); + }); - // Wait for at least 3 position updates - await intervalUpdateCompleter.future; + group('DurationRange', () { + test('uses given values', () { + const start = Duration(seconds: 2); + const end = Duration(seconds: 8); - // Verify that the intervals between updates are approximately correct - expect(positions[1] - positions[0], greaterThanOrEqualTo(updatesInterval)); - expect(positions[2] - positions[1], greaterThanOrEqualTo(updatesInterval)); - }); + final range = DurationRange(start, end); - group('DurationRange', () { - test('uses given values', () { - const start = Duration(seconds: 2); - const end = Duration(seconds: 8); + expect(range.start, start); + expect(range.end, end); + expect(range.toString(), contains('start: $start, end: $end')); + }); + + test('calculates fractions', () { + const start = Duration(seconds: 2); + const end = Duration(seconds: 8); + const total = Duration(seconds: 10); - final range = DurationRange(start, end); + final range = DurationRange(start, end); - expect(range.start, start); - expect(range.end, end); - expect(range.toString(), contains('start: $start, end: $end')); + expect(range.startFraction(total), .2); + expect(range.endFraction(total), .8); + }); }); - test('calculates fractions', () { - const start = Duration(seconds: 2); - const end = Duration(seconds: 8); - const total = Duration(seconds: 10); + group('VideoPlayerValue', () { + test('uninitialized()', () { + const uninitialized = VideoPlayerValue.uninitialized(); + + expect(uninitialized.duration, equals(Duration.zero)); + expect(uninitialized.position, equals(Duration.zero)); + expect(uninitialized.caption, equals(Caption.none)); + expect(uninitialized.captionOffset, equals(Duration.zero)); + expect(uninitialized.buffered, isEmpty); + expect(uninitialized.isPlaying, isFalse); + expect(uninitialized.isLooping, isFalse); + expect(uninitialized.isBuffering, isFalse); + expect(uninitialized.volume, 1.0); + expect(uninitialized.playbackSpeed, 1.0); + expect(uninitialized.errorDescription, isNull); + expect(uninitialized.size, equals(Size.zero)); + expect(uninitialized.isInitialized, isFalse); + expect(uninitialized.hasError, isFalse); + expect(uninitialized.aspectRatio, 1.0); + }); - final range = DurationRange(start, end); + test('erroneous()', () { + const errorMessage = 'foo'; + const error = VideoPlayerValue.erroneous(errorMessage); + + expect(error.duration, equals(Duration.zero)); + expect(error.position, equals(Duration.zero)); + expect(error.caption, equals(Caption.none)); + expect(error.captionOffset, equals(Duration.zero)); + expect(error.buffered, isEmpty); + expect(error.isPlaying, isFalse); + expect(error.isLooping, isFalse); + expect(error.isBuffering, isFalse); + expect(error.volume, 1.0); + expect(error.playbackSpeed, 1.0); + expect(error.errorDescription, errorMessage); + expect(error.size, equals(Size.zero)); + expect(error.isInitialized, isFalse); + expect(error.hasError, isTrue); + expect(error.aspectRatio, 1.0); + }); - expect(range.startFraction(total), .2); - expect(range.endFraction(total), .8); - }); - }); + test('toString()', () { + const duration = Duration(seconds: 5); + const size = Size(400, 300); + const position = Duration(seconds: 1); + const caption = Caption( + text: 'foo', + number: 0, + start: Duration.zero, + end: Duration.zero, + ); + const captionOffset = Duration(milliseconds: 250); + final buffered = [ + DurationRange(Duration.zero, const Duration(seconds: 4)), + ]; + const isInitialized = true; + const isPlaying = true; + const isLooping = true; + const isBuffering = true; + const volume = 0.5; + const playbackSpeed = 1.5; + + final value = VideoPlayerValue( + duration: duration, + size: size, + position: position, + caption: caption, + captionOffset: captionOffset, + buffered: buffered, + isInitialized: isInitialized, + isPlaying: isPlaying, + isLooping: isLooping, + isBuffering: isBuffering, + volume: volume, + playbackSpeed: playbackSpeed, + ); - group('VideoPlayerValue', () { - test('uninitialized()', () { - const uninitialized = VideoPlayerValue.uninitialized(); - - expect(uninitialized.duration, equals(Duration.zero)); - expect(uninitialized.position, equals(Duration.zero)); - expect(uninitialized.caption, equals(Caption.none)); - expect(uninitialized.captionOffset, equals(Duration.zero)); - expect(uninitialized.buffered, isEmpty); - expect(uninitialized.isPlaying, isFalse); - expect(uninitialized.isLooping, isFalse); - expect(uninitialized.isBuffering, isFalse); - expect(uninitialized.volume, 1.0); - expect(uninitialized.playbackSpeed, 1.0); - expect(uninitialized.errorDescription, isNull); - expect(uninitialized.size, equals(Size.zero)); - expect(uninitialized.isInitialized, isFalse); - expect(uninitialized.hasError, isFalse); - expect(uninitialized.aspectRatio, 1.0); - }); + expect( + value.toString(), + 'VideoPlayerValue(duration: 0:00:05.000000, ' + 'size: Size(400.0, 300.0), ' + 'position: 0:00:01.000000, ' + 'caption: Caption(number: 0, start: 0:00:00.000000, end: 0:00:00.000000, text: foo), ' + 'captionOffset: 0:00:00.250000, ' + 'buffered: [DurationRange(start: 0:00:00.000000, end: 0:00:04.000000)], ' + 'isInitialized: true, ' + 'isPlaying: true, ' + 'isLooping: true, ' + 'isBuffering: true, ' + 'volume: 0.5, ' + 'playbackSpeed: 1.5, ' + 'errorDescription: null, ' + 'isCompleted: false),', + ); + }); - test('erroneous()', () { - const errorMessage = 'foo'; - const error = VideoPlayerValue.erroneous(errorMessage); - - expect(error.duration, equals(Duration.zero)); - expect(error.position, equals(Duration.zero)); - expect(error.caption, equals(Caption.none)); - expect(error.captionOffset, equals(Duration.zero)); - expect(error.buffered, isEmpty); - expect(error.isPlaying, isFalse); - expect(error.isLooping, isFalse); - expect(error.isBuffering, isFalse); - expect(error.volume, 1.0); - expect(error.playbackSpeed, 1.0); - expect(error.errorDescription, errorMessage); - expect(error.size, equals(Size.zero)); - expect(error.isInitialized, isFalse); - expect(error.hasError, isTrue); - expect(error.aspectRatio, 1.0); - }); + group('copyWith()', () { + test('exact copy', () { + const original = VideoPlayerValue.uninitialized(); + final VideoPlayerValue exactCopy = original.copyWith(); - test('toString()', () { - const duration = Duration(seconds: 5); - const size = Size(400, 300); - const position = Duration(seconds: 1); - const caption = Caption( - text: 'foo', - number: 0, - start: Duration.zero, - end: Duration.zero, - ); - const captionOffset = Duration(milliseconds: 250); - final buffered = [ - DurationRange(Duration.zero, const Duration(seconds: 4)), - ]; - const isInitialized = true; - const isPlaying = true; - const isLooping = true; - const isBuffering = true; - const volume = 0.5; - const playbackSpeed = 1.5; - - final value = VideoPlayerValue( - duration: duration, - size: size, - position: position, - caption: caption, - captionOffset: captionOffset, - buffered: buffered, - isInitialized: isInitialized, - isPlaying: isPlaying, - isLooping: isLooping, - isBuffering: isBuffering, - volume: volume, - playbackSpeed: playbackSpeed, - ); + expect(exactCopy.toString(), original.toString()); + }); + test('errorDescription is not persisted when copy with null', () { + const original = VideoPlayerValue.erroneous('error'); + final VideoPlayerValue copy = original.copyWith( + errorDescription: null, + ); - expect( - value.toString(), - 'VideoPlayerValue(duration: 0:00:05.000000, ' - 'size: Size(400.0, 300.0), ' - 'position: 0:00:01.000000, ' - 'caption: Caption(number: 0, start: 0:00:00.000000, end: 0:00:00.000000, text: foo), ' - 'captionOffset: 0:00:00.250000, ' - 'buffered: [DurationRange(start: 0:00:00.000000, end: 0:00:04.000000)], ' - 'isInitialized: true, ' - 'isPlaying: true, ' - 'isLooping: true, ' - 'isBuffering: true, ' - 'volume: 0.5, ' - 'playbackSpeed: 1.5, ' - 'errorDescription: null, ' - 'isCompleted: false),', - ); - }); + expect(copy.errorDescription, null); + }); + test('errorDescription is changed when copy with another error', () { + const original = VideoPlayerValue.erroneous('error'); + final VideoPlayerValue copy = original.copyWith( + errorDescription: 'new error', + ); - group('copyWith()', () { - test('exact copy', () { - const original = VideoPlayerValue.uninitialized(); - final VideoPlayerValue exactCopy = original.copyWith(); + expect(copy.errorDescription, 'new error'); + }); + test('errorDescription is changed when copy with error', () { + const original = VideoPlayerValue.uninitialized(); + final VideoPlayerValue copy = original.copyWith( + errorDescription: 'new error', + ); - expect(exactCopy.toString(), original.toString()); + expect(copy.errorDescription, 'new error'); + }); }); - test('errorDescription is not persisted when copy with null', () { - const original = VideoPlayerValue.erroneous('error'); - final VideoPlayerValue copy = original.copyWith(errorDescription: null); - expect(copy.errorDescription, null); - }); - test('errorDescription is changed when copy with another error', () { - const original = VideoPlayerValue.erroneous('error'); - final VideoPlayerValue copy = original.copyWith( - errorDescription: 'new error', - ); + group('aspectRatio', () { + test('640x480 -> 4:3', () { + const value = VideoPlayerValue( + isInitialized: true, + size: Size(640, 480), + duration: Duration(seconds: 1), + ); + expect(value.aspectRatio, 4 / 3); + }); - expect(copy.errorDescription, 'new error'); - }); - test('errorDescription is changed when copy with error', () { - const original = VideoPlayerValue.uninitialized(); - final VideoPlayerValue copy = original.copyWith( - errorDescription: 'new error', - ); + test('no size -> 1.0', () { + const value = VideoPlayerValue( + isInitialized: true, + duration: Duration(seconds: 1), + ); + expect(value.aspectRatio, 1.0); + }); + + test('height = 0 -> 1.0', () { + const value = VideoPlayerValue( + isInitialized: true, + size: Size(640, 0), + duration: Duration(seconds: 1), + ); + expect(value.aspectRatio, 1.0); + }); + + test('width = 0 -> 1.0', () { + const value = VideoPlayerValue( + isInitialized: true, + size: Size(0, 480), + duration: Duration(seconds: 1), + ); + expect(value.aspectRatio, 1.0); + }); - expect(copy.errorDescription, 'new error'); + test('negative aspect ratio -> 1.0', () { + const value = VideoPlayerValue( + isInitialized: true, + size: Size(640, -480), + duration: Duration(seconds: 1), + ); + expect(value.aspectRatio, 1.0); + }); }); }); - group('aspectRatio', () { - test('640x480 -> 4:3', () { - const value = VideoPlayerValue( - isInitialized: true, - size: Size(640, 480), - duration: Duration(seconds: 1), + group('VideoPlayerOptions', () { + test('setMixWithOthers', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), ); - expect(value.aspectRatio, 4 / 3); + addTearDown(controller.dispose); + + await controller.initialize(); + expect(controller.videoPlayerOptions!.mixWithOthers, true); }); - test('no size -> 1.0', () { - const value = VideoPlayerValue( - isInitialized: true, - duration: Duration(seconds: 1), + test('true allowBackgroundPlayback continues playback', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true), ); - expect(value.aspectRatio, 1.0); - }); + addTearDown(controller.dispose); - test('height = 0 -> 1.0', () { - const value = VideoPlayerValue( - isInitialized: true, - size: Size(640, 0), - duration: Duration(seconds: 1), + await controller.initialize(); + await controller.play(); + verifyPlayStateRespondsToLifecycle( + controller, + shouldPlayInBackground: true, ); - expect(value.aspectRatio, 1.0); }); - test('width = 0 -> 1.0', () { - const value = VideoPlayerValue( - isInitialized: true, - size: Size(0, 480), - duration: Duration(seconds: 1), + test('false allowBackgroundPlayback pauses playback', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(), ); - expect(value.aspectRatio, 1.0); - }); + addTearDown(controller.dispose); - test('negative aspect ratio -> 1.0', () { - const value = VideoPlayerValue( - isInitialized: true, - size: Size(640, -480), - duration: Duration(seconds: 1), + await controller.initialize(); + await controller.play(); + verifyPlayStateRespondsToLifecycle( + controller, + shouldPlayInBackground: false, ); - expect(value.aspectRatio, 1.0); }); }); - }); - group('VideoPlayerOptions', () { - test('setMixWithOthers', () async { - final controller = VideoPlayerController.networkUrl( - _localhostUri, - videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), - ); - addTearDown(controller.dispose); - - await controller.initialize(); - expect(controller.videoPlayerOptions!.mixWithOthers, true); - }); + test('VideoProgressColors', () { + const playedColor = Color.fromRGBO(0, 0, 255, 0.75); + const bufferedColor = Color.fromRGBO(0, 255, 0, 0.5); + const backgroundColor = Color.fromRGBO(255, 255, 0, 0.25); - test('true allowBackgroundPlayback continues playback', () async { - final controller = VideoPlayerController.networkUrl( - _localhostUri, - videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true), + const colors = VideoProgressColors( + playedColor: playedColor, + bufferedColor: bufferedColor, + backgroundColor: backgroundColor, ); - addTearDown(controller.dispose); - await controller.initialize(); - await controller.play(); - verifyPlayStateRespondsToLifecycle( - controller, - shouldPlayInBackground: true, - ); + expect(colors.playedColor, playedColor); + expect(colors.bufferedColor, bufferedColor); + expect(colors.backgroundColor, backgroundColor); }); - test('false allowBackgroundPlayback pauses playback', () async { + test('isCompleted updates on video end', () async { final controller = VideoPlayerController.networkUrl( _localhostUri, videoPlayerOptions: VideoPlayerOptions(), @@ -1361,130 +1777,99 @@ void main() { addTearDown(controller.dispose); await controller.initialize(); - await controller.play(); - verifyPlayStateRespondsToLifecycle( - controller, - shouldPlayInBackground: false, - ); - }); - }); - - test('VideoProgressColors', () { - const playedColor = Color.fromRGBO(0, 0, 255, 0.75); - const bufferedColor = Color.fromRGBO(0, 255, 0, 0.5); - const backgroundColor = Color.fromRGBO(255, 255, 0, 0.25); - - const colors = VideoProgressColors( - playedColor: playedColor, - bufferedColor: bufferedColor, - backgroundColor: backgroundColor, - ); - - expect(colors.playedColor, playedColor); - expect(colors.bufferedColor, bufferedColor); - expect(colors.backgroundColor, backgroundColor); - }); - - test('isCompleted updates on video end', () async { - final controller = VideoPlayerController.networkUrl( - _localhostUri, - videoPlayerOptions: VideoPlayerOptions(), - ); - addTearDown(controller.dispose); - await controller.initialize(); + final StreamController fakeVideoEventStream = + fakeVideoPlayerPlatform.streams[controller.playerId]!; - final StreamController fakeVideoEventStream = - fakeVideoPlayerPlatform.streams[controller.playerId]!; + bool currentIsCompleted = controller.value.isCompleted; - bool currentIsCompleted = controller.value.isCompleted; + final void Function() isCompletedTest = expectAsync0(() {}); - final void Function() isCompletedTest = expectAsync0(() {}); - - controller.addListener(() async { - if (currentIsCompleted != controller.value.isCompleted) { - currentIsCompleted = controller.value.isCompleted; - if (controller.value.isCompleted) { - isCompletedTest(); + controller.addListener(() async { + if (currentIsCompleted != controller.value.isCompleted) { + currentIsCompleted = controller.value.isCompleted; + if (controller.value.isCompleted) { + isCompletedTest(); + } } - } + }); + + fakeVideoEventStream.add(VideoEvent(eventType: VideoEventType.completed)); }); - fakeVideoEventStream.add(VideoEvent(eventType: VideoEventType.completed)); - }); + test('isCompleted updates on video play after completed', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(), + ); + addTearDown(controller.dispose); - test('isCompleted updates on video play after completed', () async { - final controller = VideoPlayerController.networkUrl( - _localhostUri, - videoPlayerOptions: VideoPlayerOptions(), - ); - addTearDown(controller.dispose); + await controller.initialize(); - await controller.initialize(); - - final StreamController fakeVideoEventStream = - fakeVideoPlayerPlatform.streams[controller.playerId]!; - - bool currentIsCompleted = controller.value.isCompleted; - - final void Function() isCompletedTest = expectAsync0(() {}, count: 2); - final void Function() isNoLongerCompletedTest = expectAsync0(() {}); - var hasLooped = false; - - controller.addListener(() async { - if (currentIsCompleted != controller.value.isCompleted) { - currentIsCompleted = controller.value.isCompleted; - if (controller.value.isCompleted) { - isCompletedTest(); - if (!hasLooped) { - fakeVideoEventStream.add( - VideoEvent( - eventType: VideoEventType.isPlayingStateUpdate, - isPlaying: true, - ), - ); - hasLooped = !hasLooped; + final StreamController fakeVideoEventStream = + fakeVideoPlayerPlatform.streams[controller.playerId]!; + + bool currentIsCompleted = controller.value.isCompleted; + + final void Function() isCompletedTest = expectAsync0(() {}, count: 2); + final void Function() isNoLongerCompletedTest = expectAsync0(() {}); + var hasLooped = false; + + controller.addListener(() async { + if (currentIsCompleted != controller.value.isCompleted) { + currentIsCompleted = controller.value.isCompleted; + if (controller.value.isCompleted) { + isCompletedTest(); + if (!hasLooped) { + fakeVideoEventStream.add( + VideoEvent( + eventType: VideoEventType.isPlayingStateUpdate, + isPlaying: true, + ), + ); + hasLooped = !hasLooped; + } + } else { + isNoLongerCompletedTest(); } - } else { - isNoLongerCompletedTest(); } - } - }); + }); - fakeVideoEventStream.add(VideoEvent(eventType: VideoEventType.completed)); - }); + fakeVideoEventStream.add(VideoEvent(eventType: VideoEventType.completed)); + }); - test('isCompleted updates on video seek to end', () async { - final controller = VideoPlayerController.networkUrl( - _localhostUri, - videoPlayerOptions: VideoPlayerOptions(), - ); - addTearDown(controller.dispose); + test('isCompleted updates on video seek to end', () async { + final controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(), + ); + addTearDown(controller.dispose); - await controller.initialize(); + await controller.initialize(); - bool currentIsCompleted = controller.value.isCompleted; + bool currentIsCompleted = controller.value.isCompleted; - final void Function() isCompletedTest = expectAsync0(() {}); + final void Function() isCompletedTest = expectAsync0(() {}); - controller.value = controller.value.copyWith( - duration: const Duration(seconds: 10), - ); + controller.value = controller.value.copyWith( + duration: const Duration(seconds: 10), + ); - controller.addListener(() async { - if (currentIsCompleted != controller.value.isCompleted) { - currentIsCompleted = controller.value.isCompleted; - if (controller.value.isCompleted) { - isCompletedTest(); + controller.addListener(() async { + if (currentIsCompleted != controller.value.isCompleted) { + currentIsCompleted = controller.value.isCompleted; + if (controller.value.isCompleted) { + isCompletedTest(); + } } - } - }); + }); - // This call won't update isCompleted. - // The test will fail if `isCompletedTest` is called more than once. - await controller.seekTo(const Duration(seconds: 10)); + // This call won't update isCompleted. + // The test will fail if `isCompletedTest` is called more than once. + await controller.seekTo(const Duration(seconds: 10)); - await controller.seekTo(const Duration(seconds: 20)); + await controller.seekTo(const Duration(seconds: 20)); + }); }); }