diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index e3716f7e282a..2ead65b4fcfe 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.7.2 + +* Adds `isCompleted` event to `VideoPlayerEvent`. + ## 2.7.1 * Adds pub topics to package metadata. diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index b0e5b7b0fa94..5bd543cd49ab 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -52,6 +52,7 @@ class VideoPlayerValue { this.playbackSpeed = 1.0, this.rotationCorrection = 0, this.errorDescription, + this.isCompleted = false, }); /// Returns an instance for a video that hasn't been loaded. @@ -111,6 +112,12 @@ class VideoPlayerValue { /// If [hasError] is false this is `null`. final String? errorDescription; + /// True if video has finished playing to end. + /// + /// Reverts to false if video position changes, or video begins playing. + /// Does not update if video is looping. + final bool isCompleted; + /// The [size] of the currently loaded video. final Size size; @@ -158,6 +165,7 @@ class VideoPlayerValue { double? playbackSpeed, int? rotationCorrection, String? errorDescription = _defaultErrorDescription, + bool? isCompleted, }) { return VideoPlayerValue( duration: duration ?? this.duration, @@ -176,6 +184,7 @@ class VideoPlayerValue { errorDescription: errorDescription != _defaultErrorDescription ? errorDescription : this.errorDescription, + isCompleted: isCompleted ?? this.isCompleted, ); } @@ -194,7 +203,8 @@ class VideoPlayerValue { 'isBuffering: $isBuffering, ' 'volume: $volume, ' 'playbackSpeed: $playbackSpeed, ' - 'errorDescription: $errorDescription)'; + 'errorDescription: $errorDescription, ' + 'isCompleted: $isCompleted),'; } @override @@ -215,7 +225,8 @@ class VideoPlayerValue { errorDescription == other.errorDescription && size == other.size && rotationCorrection == other.rotationCorrection && - isInitialized == other.isInitialized; + isInitialized == other.isInitialized && + isCompleted == other.isCompleted; @override int get hashCode => Object.hash( @@ -233,6 +244,7 @@ class VideoPlayerValue { size, rotationCorrection, isInitialized, + isCompleted, ); } @@ -441,6 +453,7 @@ class VideoPlayerController extends ValueNotifier { rotationCorrection: event.rotationCorrection, isInitialized: event.duration != null, errorDescription: null, + isCompleted: false, ); initializingCompleter.complete(null); _applyLooping(); @@ -453,6 +466,7 @@ class VideoPlayerController extends ValueNotifier { // we use pause() and seekTo() to ensure the platform stops playing // and seeks to the last frame of the video. pause().then((void pauseResult) => seekTo(value.duration)); + value = value.copyWith(isCompleted: true); break; case VideoEventType.bufferingUpdate: value = value.copyWith(buffered: event.buffered); @@ -464,7 +478,12 @@ class VideoPlayerController extends ValueNotifier { value = value.copyWith(isBuffering: false); break; case VideoEventType.isPlayingStateUpdate: - value = value.copyWith(isPlaying: event.isPlaying); + if (event.isPlaying ?? false) { + value = + value.copyWith(isPlaying: event.isPlaying, isCompleted: false); + } else { + value = value.copyWith(isPlaying: event.isPlaying); + } break; case VideoEventType.unknown: break; @@ -737,6 +756,7 @@ class VideoPlayerController extends ValueNotifier { value = value.copyWith( position: position, caption: _getCaptionAt(position), + isCompleted: position == value.duration, ); } diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 4e8ba4378fd8..633c2d576754 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, 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.7.1 +version: 2.7.2 environment: sdk: ">=2.19.0 <4.0.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 040cd5cb2bc1..820061ebec36 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -1069,7 +1069,8 @@ void main() { 'isBuffering: true, ' 'volume: 0.5, ' 'playbackSpeed: 1.5, ' - 'errorDescription: null)'); + 'errorDescription: null, ' + 'isCompleted: false),'); }); group('copyWith()', () { @@ -1204,6 +1205,101 @@ void main() { expect(colors.bufferedColor, bufferedColor); expect(colors.backgroundColor, backgroundColor); }); + + test('isCompleted updates on video end', () async { + final VideoPlayerController controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(), + ); + + await controller.initialize(); + + final StreamController fakeVideoEventStream = + fakeVideoPlayerPlatform.streams[controller.textureId]!; + + bool currentIsCompleted = controller.value.isCompleted; + + final void Function() isCompletedTest = expectAsync0(() {}); + + controller.addListener(() async { + if (currentIsCompleted != controller.value.isCompleted) { + currentIsCompleted = controller.value.isCompleted; + if (controller.value.isCompleted) { + isCompletedTest(); + } + } + }); + + fakeVideoEventStream.add(VideoEvent(eventType: VideoEventType.completed)); + }); + + test('isCompleted updates on video play after completed', () async { + final VideoPlayerController controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(), + ); + + await controller.initialize(); + + final StreamController fakeVideoEventStream = + fakeVideoPlayerPlatform.streams[controller.textureId]!; + + bool currentIsCompleted = controller.value.isCompleted; + + final void Function() isCompletedTest = expectAsync0(() {}, count: 2); + final void Function() isNoLongerCompletedTest = expectAsync0(() {}); + bool 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(); + } + } + }); + + fakeVideoEventStream.add(VideoEvent(eventType: VideoEventType.completed)); + }); + + test('isCompleted updates on video seek to end', () async { + final VideoPlayerController controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(), + ); + + await controller.initialize(); + + bool currentIsCompleted = controller.value.isCompleted; + + final void Function() isCompletedTest = expectAsync0(() {}); + + 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(); + } + } + }); + + // 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)); + }); } class FakeVideoPlayerPlatform extends VideoPlayerPlatform {