diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 1bac34f6649b..d4824c461a55 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.3+3 + +* Add DartDocs and unit tests. + ## 0.10.3+2 * Update the homepage to point to the new plugin location diff --git a/packages/video_player/video_player/analysis_options.yaml b/packages/video_player/video_player/analysis_options.yaml new file mode 100644 index 000000000000..6c7fd57bb022 --- /dev/null +++ b/packages/video_player/video_player/analysis_options.yaml @@ -0,0 +1,11 @@ +# This exists to add a lint for missing API docs just on this specific package, +# since not all packages have coverage for all their public members yet and +# adding it in would be non-trivial. `public_member_api_docs` should be applied +# to new packages going forward, and ideally the main `analysis_options.yaml` +# file as soon as possible. + +include: ../../../analysis_options.yaml + +linter: + rules: + - public_member_api_docs diff --git a/packages/video_player/video_player/example/lib/main.dart b/packages/video_player/video_player/example/lib/main.dart index 43d9355c4345..f55d3f7dd593 100644 --- a/packages/video_player/video_player/example/lib/main.dart +++ b/packages/video_player/video_player/example/lib/main.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// ignore_for_file: public_member_api_docs + /// An example of using the plugin, controlling lifecycle and playback of the /// video. diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index f1b0e7c9791d..f92693e79eda 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -14,16 +14,46 @@ final MethodChannel _channel = const MethodChannel('flutter.io/videoPlayer') // performed. ..invokeMethod('init'); +/// Describes a discrete segment of time within a video using a [start] and +/// [end] [Duration]. class DurationRange { + /// Trusts that the given [start] and [end] are actually in order. They should + /// both be non-null. DurationRange(this.start, this.end); + /// The beginning of the segment described relative to the beginning of the + /// entire video. Should be shorter than or equal to [end]. + /// + /// For example, if the entire video is 4 minutes long and the range is from + /// 1:00-2:00, this should be a `Duration` of one minute. final Duration start; + + /// The end of the segment described as a duration relative to the beginning of + /// the entire video. This is expected to be non-null and longer than or equal + /// to [start]. + /// + /// For example, if the entire video is 4 minutes long and the range is from + /// 1:00-2:00, this should be a `Duration` of two minutes. final Duration end; + /// Assumes that [duration] is the total length of the video that this + /// DurationRange is a segment form. It returns the percentage that [start] is + /// through the entire video. + /// + /// For example, assume that the entire video is 4 minutes long. If [start] has + /// a duration of one minute, this will return `0.25` since the DurationRange + /// starts 25% of the way through the video's total length. double startFraction(Duration duration) { return start.inMilliseconds / duration.inMilliseconds; } + /// Assumes that [duration] is the total length of the video that this + /// DurationRange is a segment form. It returns the percentage that [start] is + /// through the entire video. + /// + /// For example, assume that the entire video is 4 minutes long. If [end] has a + /// duration of two minutes, this will return `0.5` since the DurationRange + /// ends 50% of the way through the video's total length. double endFraction(Duration duration) { return end.inMilliseconds / duration.inMilliseconds; } @@ -32,11 +62,26 @@ class DurationRange { String toString() => '$runtimeType(start: $start, end: $end)'; } -enum VideoFormat { dash, hls, ss, other } +/// The file format of the given video. +enum VideoFormat { + /// Dynamic Adaptive Streaming over HTTP, also known as MPEG-DASH. + dash, + + /// HTTP Live Streaming. + hls, + + /// Smooth Streaming. + ss, + + /// Any format other than the other ones defined in this enum. + other +} /// The duration, current position, buffering state, error state and settings /// of a [VideoPlayerController]. class VideoPlayerValue { + /// Constructs a video with the given values. Only [duration] is required. The + /// rest will initialize with default values when unset. VideoPlayerValue({ @required this.duration, this.size, @@ -49,8 +94,11 @@ class VideoPlayerValue { this.errorDescription, }); + /// Returns an instance with a `null` [Duration]. VideoPlayerValue.uninitialized() : this(duration: null); + /// Returns an instance with a `null` [Duration] and the given + /// [errorDescription]. VideoPlayerValue.erroneous(String errorDescription) : this(duration: null, errorDescription: errorDescription); @@ -87,10 +135,19 @@ class VideoPlayerValue { /// Is null when [initialized] is false. final Size size; + /// Indicates whether or not the video has been loaded and is ready to play. bool get initialized => duration != null; + + /// Indicates whether or not the video is in an error state. If this is true + /// [errorDescription] should have information about the problem. bool get hasError => errorDescription != null; + + /// Returns [size.width] / [size.height] when size is non-null, or `1.0.` when + /// it is. double get aspectRatio => size != null ? size.width / size.height : 1.0; + /// Returns a new instance that has the same values as this current instance, + /// except for any overrides passed in as arguments to [copyWidth]. VideoPlayerValue copyWith({ Duration duration, Size size, @@ -130,7 +187,19 @@ class VideoPlayerValue { } } -enum DataSourceType { asset, network, file } +/// The way in which the video was originally loaded. This has nothing to do +/// with the video's file type. It's just the place from which the video is +/// fetched from. +enum DataSourceType { + /// The video was included in the app's asset files. + asset, + + /// The video was downloaded from the internet. + network, + + /// The video was loaded off of the local filesystem. + file +} /// Controls a platform video player, and provides updates when the state is /// changing. @@ -177,13 +246,20 @@ class VideoPlayerController extends ValueNotifier { super(VideoPlayerValue(duration: null)); int _textureId; + + /// The URI to the video file. This will be in different formats depending on + /// the [DataSourceType] of the original video. final String dataSource; + + /// **Android only**. Will override the platform's generic file format + /// detection with whatever is set here. final VideoFormat formatHint; /// Describes the type of data source this [VideoPlayerController] /// is constructed with. final DataSourceType dataSourceType; + /// Only set for [asset] videos. The package that the asset was loaded from. final String package; Timer _timer; bool _isDisposed = false; @@ -191,9 +267,12 @@ class VideoPlayerController extends ValueNotifier { StreamSubscription _eventSubscription; _VideoAppLifeCycleObserver _lifeCycleObserver; + /// This is just exposed for testing. It shouldn't be used by anyone depending + /// on the plugin. @visibleForTesting int get textureId => _textureId; + /// Attempts to open the given [dataSource] and load metadata about the video. Future initialize() async { _lifeCycleObserver = _VideoAppLifeCycleObserver(this); _lifeCycleObserver.initialize(); @@ -305,16 +384,24 @@ class VideoPlayerController extends ValueNotifier { super.dispose(); } + /// Starts playing the video. + /// + /// This method returns a future that completes as soon as the "play" command + /// has been sent to the platform, not when playback itself is totally + /// finished. Future play() async { value = value.copyWith(isPlaying: true); await _applyPlayPause(); } + /// Sets whether or not the video should loop after playing once. See also + /// [VideoPlayerValue.isLooping]. Future setLooping(bool looping) async { value = value.copyWith(isLooping: looping); await _applyLooping(); } + /// Pauses the video. Future pause() async { value = value.copyWith(isPlaying: false); await _applyPlayPause(); @@ -384,6 +471,11 @@ class VideoPlayerController extends ValueNotifier { ); } + /// Sets the video's current timestamp to be at [moment]. The next + /// time the video is played it will resume from the given [moment]. + /// + /// If [moment] is outside of the video's full range it will be automatically + /// and silently clamped. Future seekTo(Duration moment) async { if (_isDisposed) { return; @@ -449,10 +541,13 @@ class _VideoAppLifeCycleObserver extends Object with WidgetsBindingObserver { } } -/// Displays the video controlled by [controller]. +/// Widget that displays the video controlled by [controller]. class VideoPlayer extends StatefulWidget { + /// Uses the given [controller] for all video rendered in this widget. VideoPlayer(this.controller); + /// The [VideoPlayerController] responsible for the video being rendered in + /// this widget. final VideoPlayerController controller; @override @@ -503,15 +598,43 @@ class _VideoPlayerState extends State { } } +/// Used to configure the [VideoProgressIndicator] widget's colors for how it +/// describes the video's status. +/// +/// The widget uses default colors that are customizeable through this class. class VideoProgressColors { + /// Any property can be set to any color. They each have defaults. + /// + /// [playedColor] defaults to red at 70% opacity. This fills up a portion of + /// the [VideoProgressIndicator] to represent how much of the video has played + /// so far. + /// + /// [bufferedColor] defaults to blue at 20% opacity. This fills up a portion + /// of [VideoProgressIndicator] to represent how much of the video has + /// buffered so far. + /// + /// [backgroundColor] defaults to gray at 50% opacity. This is the background + /// color behind both [playedColor] and [bufferedColor] to denote the total + /// size of the video compared to either of those values. VideoProgressColors({ this.playedColor = const Color.fromRGBO(255, 0, 0, 0.7), this.bufferedColor = const Color.fromRGBO(50, 50, 200, 0.2), this.backgroundColor = const Color.fromRGBO(200, 200, 200, 0.5), }); + /// [playedColor] defaults to red at 70% opacity. This fills up a portion of + /// the [VideoProgressIndicator] to represent how much of the video has played + /// so far. final Color playedColor; + + /// [bufferedColor] defaults to blue at 20% opacity. This fills up a portion + /// of [VideoProgressIndicator] to represent how much of the video has + /// buffered so far. final Color bufferedColor; + + /// [backgroundColor] defaults to gray at 50% opacity. This is the background + /// color behind both [playedColor] and [bufferedColor] to denote the total + /// size of the video compared to either of those values. final Color backgroundColor; } @@ -584,6 +707,12 @@ class _VideoScrubberState extends State<_VideoScrubber> { /// [padding] allows to specify some extra padding around the progress indicator /// that will also detect the gestures. class VideoProgressIndicator extends StatefulWidget { + /// Construct an instance that displays the play/buffering status of the video + /// controlled by [controller]. + /// + /// Defaults will be used for everything except [controller] if they're not + /// provided. [allowScrubbing] defaults to false, and [padding] will default + /// to `top: 5.0`. VideoProgressIndicator( this.controller, { VideoProgressColors colors, @@ -591,9 +720,25 @@ class VideoProgressIndicator extends StatefulWidget { this.padding = const EdgeInsets.only(top: 5.0), }) : colors = colors ?? VideoProgressColors(); + /// The [VideoPlayerController] that actually associates a video with this + /// widget. final VideoPlayerController controller; + + /// The default colors used throughout the indicator. + /// + /// See [VideoProgressColors] for default values. final VideoProgressColors colors; + + /// When true, the widget will detect touch input and try to seek the video + /// accordingly. The widget ignores such input when false. + /// + /// Defaults to false. final bool allowScrubbing; + + /// This allows for visual padding around the progress indicator that can + /// still detect gestures via [allowScrubbing]. + /// + /// Defaults to `top: 5.0`. final EdgeInsets padding; @override diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 32d1e98e586c..cf85568de389 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -2,8 +2,8 @@ name: video_player description: Flutter plugin for displaying inline video with other Flutter widgets on Android and iOS. author: Flutter Team -version: 0.10.3+2 -homepage: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player +version: 0.10.3+3 +homepage: https://github.com/flutter/plugins/tree/master/packages/video_player flutter: plugin: 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 9211f0c3a87c..10b5754bbad2 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -90,56 +90,339 @@ void main() { fakeVideoPlayerPlatform = FakeVideoPlayerPlatform(); }); - test('initialize asset', () async { - final VideoPlayerController controller = VideoPlayerController.asset( - 'a.avi', + group('initialize', () { + test('asset', () async { + final VideoPlayerController controller = VideoPlayerController.asset( + 'a.avi', + ); + await controller.initialize(); + + expect( + fakeVideoPlayerPlatform.dataSourceDescriptions[0], + { + 'asset': 'a.avi', + 'package': null, + }); + }); + + test('network', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + await controller.initialize(); + + expect( + fakeVideoPlayerPlatform.dataSourceDescriptions[0], + { + 'uri': 'https://127.0.0.1', + 'formatHint': null, + }); + }); + + test('network with hint', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + formatHint: VideoFormat.dash); + await controller.initialize(); + + expect( + fakeVideoPlayerPlatform.dataSourceDescriptions[0], + { + 'uri': 'https://127.0.0.1', + 'formatHint': 'dash', + }); + }); + + test('file', () async { + final VideoPlayerController controller = + VideoPlayerController.file(File('a.avi')); + await controller.initialize(); + + expect( + fakeVideoPlayerPlatform.dataSourceDescriptions[0], + { + 'uri': 'file://a.avi', + }); + }); + }); + + test('dispose', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + expect(controller.textureId, isNull); + expect(await controller.position, const Duration(seconds: 0)); + controller.initialize(); + + await controller.dispose(); + + expect(controller.textureId, isNotNull); + expect(await controller.position, isNull); + }); + + test('play', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', ); await controller.initialize(); + expect(controller.value.isPlaying, isFalse); + await controller.play(); - expect( - fakeVideoPlayerPlatform.dataSourceDescriptions[0], { - 'asset': 'a.avi', - 'package': null, - }); + expect(controller.value.isPlaying, isTrue); + expect(fakeVideoPlayerPlatform.calls.last.method, 'play'); }); - test('initialize network', () async { + test('setLooping', () async { final VideoPlayerController controller = VideoPlayerController.network( 'https://127.0.0.1', ); await controller.initialize(); + expect(controller.value.isLooping, isFalse); + await controller.setLooping(true); - expect( - fakeVideoPlayerPlatform.dataSourceDescriptions[0], { - 'uri': 'https://127.0.0.1', - 'formatHint': null, - }); + expect(controller.value.isLooping, isTrue); }); - test('initialize network with hint', () async { + test('pause', () async { final VideoPlayerController controller = VideoPlayerController.network( - 'https://127.0.0.1', - formatHint: VideoFormat.dash); + 'https://127.0.0.1', + ); await controller.initialize(); + await controller.play(); + expect(controller.value.isPlaying, isTrue); + + await controller.pause(); + + expect(controller.value.isPlaying, isFalse); + expect(fakeVideoPlayerPlatform.calls.last.method, 'pause'); + }); + + group('seekTo', () { + test('works', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + await controller.initialize(); + expect(await controller.position, const Duration(seconds: 0)); - expect( - fakeVideoPlayerPlatform.dataSourceDescriptions[0], { - 'uri': 'https://127.0.0.1', - 'formatHint': 'dash', + await controller.seekTo(const Duration(milliseconds: 500)); + + expect(await controller.position, const Duration(milliseconds: 500)); + }); + + test('clamps values that are too high or low', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + await controller.initialize(); + expect(await controller.position, const Duration(seconds: 0)); + + await controller.seekTo(const Duration(seconds: 100)); + expect(await controller.position, const Duration(seconds: 1)); + + await controller.seekTo(const Duration(seconds: -100)); + expect(await controller.position, const Duration(seconds: 0)); }); }); - test('initialize file', () async { - final VideoPlayerController controller = - VideoPlayerController.file(File('a.avi')); - await controller.initialize(); + group('setVolume', () { + test('works', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + await controller.initialize(); + expect(controller.value.volume, 1.0); - expect( - fakeVideoPlayerPlatform.dataSourceDescriptions[0], { - 'uri': 'file://a.avi', + const double volume = 0.5; + await controller.setVolume(volume); + + expect(controller.value.volume, volume); + }); + + test('clamps values that are too high or low', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + await controller.initialize(); + expect(controller.value.volume, 1.0); + + await controller.setVolume(-1); + expect(controller.value.volume, 0.0); + + await controller.setVolume(11); + expect(controller.value.volume, 1.0); + }); + }); + + group('Platform callbacks', () { + testWidgets('playing completed', (WidgetTester tester) async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + await controller.initialize(); + expect(controller.value.isPlaying, isFalse); + await controller.play(); + expect(controller.value.isPlaying, isTrue); + final FakeVideoEventStream fakeVideoEventStream = + fakeVideoPlayerPlatform.streams[controller.textureId]; + assert(fakeVideoEventStream != null); + + fakeVideoEventStream.eventsChannel + .sendEvent({'event': 'completed'}); + await tester.pumpAndSettle(); + + expect(controller.value.isPlaying, isFalse); + expect(controller.value.position, controller.value.duration); + }); + + testWidgets('buffering status', (WidgetTester tester) async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + await controller.initialize(); + expect(controller.value.isBuffering, false); + expect(controller.value.buffered, isEmpty); + final FakeVideoEventStream fakeVideoEventStream = + fakeVideoPlayerPlatform.streams[controller.textureId]; + assert(fakeVideoEventStream != null); + + fakeVideoEventStream.eventsChannel + .sendEvent({'event': 'bufferingStart'}); + await tester.pumpAndSettle(); + expect(controller.value.isBuffering, isTrue); + + const Duration bufferStart = Duration(seconds: 0); + const Duration bufferEnd = Duration(milliseconds: 500); + fakeVideoEventStream.eventsChannel.sendEvent({ + 'event': 'bufferingUpdate', + 'values': >[ + [bufferStart.inMilliseconds, bufferEnd.inMilliseconds] + ], + }); + await tester.pumpAndSettle(); + expect(controller.value.isBuffering, isTrue); + expect(controller.value.buffered.length, 1); + expect(controller.value.buffered[0].toString(), + DurationRange(bufferStart, bufferEnd).toString()); + + fakeVideoEventStream.eventsChannel + .sendEvent({'event': 'bufferingEnd'}); + await tester.pumpAndSettle(); + expect(controller.value.isBuffering, isFalse); }); }); }); + + group('DurationRange', () { + test('uses given values', () { + const Duration start = Duration(seconds: 2); + const Duration end = Duration(seconds: 8); + + final DurationRange range = DurationRange(start, end); + + expect(range.start, start); + expect(range.end, end); + expect(range.toString(), contains('start: $start, end: $end')); + }); + + test('calculates fractions', () { + const Duration start = Duration(seconds: 2); + const Duration end = Duration(seconds: 8); + const Duration total = Duration(seconds: 10); + + final DurationRange range = DurationRange(start, end); + + expect(range.startFraction(total), .2); + expect(range.endFraction(total), .8); + }); + }); + + group('VideoPlayerValue', () { + test('uninitialized()', () { + final VideoPlayerValue uninitialized = VideoPlayerValue.uninitialized(); + + expect(uninitialized.duration, isNull); + expect(uninitialized.position, equals(const Duration(seconds: 0))); + expect(uninitialized.buffered, isEmpty); + expect(uninitialized.isPlaying, isFalse); + expect(uninitialized.isLooping, isFalse); + expect(uninitialized.isBuffering, isFalse); + expect(uninitialized.volume, 1.0); + expect(uninitialized.errorDescription, isNull); + expect(uninitialized.size, isNull); + expect(uninitialized.size, isNull); + expect(uninitialized.initialized, isFalse); + expect(uninitialized.hasError, isFalse); + expect(uninitialized.aspectRatio, 1.0); + }); + + test('erroneous()', () { + const String errorMessage = 'foo'; + final VideoPlayerValue error = VideoPlayerValue.erroneous(errorMessage); + + expect(error.duration, isNull); + expect(error.position, equals(const Duration(seconds: 0))); + expect(error.buffered, isEmpty); + expect(error.isPlaying, isFalse); + expect(error.isLooping, isFalse); + expect(error.isBuffering, isFalse); + expect(error.volume, 1.0); + expect(error.errorDescription, errorMessage); + expect(error.size, isNull); + expect(error.size, isNull); + expect(error.initialized, isFalse); + expect(error.hasError, isTrue); + expect(error.aspectRatio, 1.0); + }); + + test('toString()', () { + const Duration duration = Duration(seconds: 5); + const Size size = Size(400, 300); + const Duration position = Duration(seconds: 1); + final List buffered = [ + DurationRange(const Duration(seconds: 0), const Duration(seconds: 4)) + ]; + const bool isPlaying = true; + const bool isLooping = true; + const bool isBuffering = true; + const double volume = 0.5; + + final VideoPlayerValue value = VideoPlayerValue( + duration: duration, + size: size, + position: position, + buffered: buffered, + isPlaying: isPlaying, + isLooping: isLooping, + isBuffering: isBuffering, + volume: volume); + + expect(value.toString(), + 'VideoPlayerValue(duration: 0:00:05.000000, size: Size(400.0, 300.0), position: 0:00:01.000000, buffered: [DurationRange(start: 0:00:00.000000, end: 0:00:04.000000)], isPlaying: true, isLooping: true, isBuffering: truevolume: 0.5, errorDescription: null)'); + }); + + test('copyWith()', () { + final VideoPlayerValue original = VideoPlayerValue.uninitialized(); + final VideoPlayerValue exactCopy = original.copyWith(); + + expect(exactCopy.toString(), original.toString()); + }); + }); + + test('VideoProgressColors', () { + const Color playedColor = Color.fromRGBO(0, 0, 255, 0.75); + const Color bufferedColor = Color.fromRGBO(0, 255, 0, 0.5); + const Color backgroundColor = Color.fromRGBO(255, 255, 0, 0.25); + + final VideoProgressColors colors = VideoProgressColors( + playedColor: playedColor, + bufferedColor: bufferedColor, + backgroundColor: backgroundColor); + + expect(colors.playedColor, playedColor); + expect(colors.bufferedColor, bufferedColor); + expect(colors.backgroundColor, backgroundColor); + }); } class FakeVideoPlayerPlatform { @@ -150,16 +433,20 @@ class FakeVideoPlayerPlatform { final MethodChannel _channel = const MethodChannel('flutter.io/videoPlayer'); Completer initialized = Completer(); + List calls = []; List> dataSourceDescriptions = >[]; + final Map streams = {}; int nextTextureId = 0; + final Map _positions = {}; Future onMethodCall(MethodCall call) { + calls.add(call); switch (call.method) { case 'init': initialized.complete(true); break; case 'create': - FakeVideoEventStream( + streams[nextTextureId] = FakeVideoEventStream( nextTextureId, 100, 100, const Duration(seconds: 1)); final Map dataSource = call.arguments; dataSourceDescriptions.add(dataSource.cast()); @@ -169,11 +456,20 @@ class FakeVideoPlayerPlatform { }; }); break; - case 'setLooping': + case 'position': + final Duration position = _positions[call.arguments['textureId']] ?? + const Duration(seconds: 0); + return Future.value(position.inMilliseconds); break; - case 'setVolume': + case 'seekTo': + _positions[call.arguments['textureId']] = + Duration(milliseconds: call.arguments['location']); break; + case 'dispose': case 'pause': + case 'play': + case 'setLooping': + case 'setVolume': break; default: throw UnimplementedError(