| // Copyright 2017 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:io'; |
| |
| import 'package:flutter/services.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:meta/meta.dart'; |
| |
| final MethodChannel _channel = const MethodChannel('flutter.io/videoPlayer') |
| // This will clear all open videos on the platform when a full restart is |
| // performed. |
| ..invokeMethod<void>('init'); |
| |
| class DurationRange { |
| DurationRange(this.start, this.end); |
| |
| final Duration start; |
| final Duration end; |
| |
| double startFraction(Duration duration) { |
| return start.inMilliseconds / duration.inMilliseconds; |
| } |
| |
| double endFraction(Duration duration) { |
| return end.inMilliseconds / duration.inMilliseconds; |
| } |
| |
| @override |
| String toString() => '$runtimeType(start: $start, end: $end)'; |
| } |
| |
| enum VideoFormat { dash, hls, ss, other } |
| |
| /// The duration, current position, buffering state, error state and settings |
| /// of a [VideoPlayerController]. |
| class VideoPlayerValue { |
| VideoPlayerValue({ |
| @required this.duration, |
| this.size, |
| this.position = const Duration(), |
| this.buffered = const <DurationRange>[], |
| this.isPlaying = false, |
| this.isLooping = false, |
| this.isBuffering = false, |
| this.volume = 1.0, |
| this.errorDescription, |
| }); |
| |
| VideoPlayerValue.uninitialized() : this(duration: null); |
| |
| VideoPlayerValue.erroneous(String errorDescription) |
| : this(duration: null, errorDescription: errorDescription); |
| |
| /// The total duration of the video. |
| /// |
| /// Is null when [initialized] is false. |
| final Duration duration; |
| |
| /// The current playback position. |
| final Duration position; |
| |
| /// The currently buffered ranges. |
| final List<DurationRange> buffered; |
| |
| /// True if the video is playing. False if it's paused. |
| final bool isPlaying; |
| |
| /// True if the video is looping. |
| final bool isLooping; |
| |
| /// True if the video is currently buffering. |
| final bool isBuffering; |
| |
| /// The current volume of the playback. |
| final double volume; |
| |
| /// A description of the error if present. |
| /// |
| /// If [hasError] is false this is [null]. |
| final String errorDescription; |
| |
| /// The [size] of the currently loaded video. |
| /// |
| /// Is null when [initialized] is false. |
| final Size size; |
| |
| bool get initialized => duration != null; |
| bool get hasError => errorDescription != null; |
| double get aspectRatio => size != null ? size.width / size.height : 1.0; |
| |
| VideoPlayerValue copyWith({ |
| Duration duration, |
| Size size, |
| Duration position, |
| List<DurationRange> buffered, |
| bool isPlaying, |
| bool isLooping, |
| bool isBuffering, |
| double volume, |
| String errorDescription, |
| }) { |
| return VideoPlayerValue( |
| duration: duration ?? this.duration, |
| size: size ?? this.size, |
| position: position ?? this.position, |
| buffered: buffered ?? this.buffered, |
| isPlaying: isPlaying ?? this.isPlaying, |
| isLooping: isLooping ?? this.isLooping, |
| isBuffering: isBuffering ?? this.isBuffering, |
| volume: volume ?? this.volume, |
| errorDescription: errorDescription ?? this.errorDescription, |
| ); |
| } |
| |
| @override |
| String toString() { |
| return '$runtimeType(' |
| 'duration: $duration, ' |
| 'size: $size, ' |
| 'position: $position, ' |
| 'buffered: [${buffered.join(', ')}], ' |
| 'isPlaying: $isPlaying, ' |
| 'isLooping: $isLooping, ' |
| 'isBuffering: $isBuffering' |
| 'volume: $volume, ' |
| 'errorDescription: $errorDescription)'; |
| } |
| } |
| |
| enum DataSourceType { asset, network, file } |
| |
| /// Controls a platform video player, and provides updates when the state is |
| /// changing. |
| /// |
| /// Instances must be initialized with initialize. |
| /// |
| /// The video is displayed in a Flutter app by creating a [VideoPlayer] widget. |
| /// |
| /// To reclaim the resources used by the player call [dispose]. |
| /// |
| /// After [dispose] all further calls are ignored. |
| class VideoPlayerController extends ValueNotifier<VideoPlayerValue> { |
| /// Constructs a [VideoPlayerController] playing a video from an asset. |
| /// |
| /// The name of the asset is given by the [dataSource] argument and must not be |
| /// null. The [package] argument must be non-null when the asset comes from a |
| /// package and null otherwise. |
| VideoPlayerController.asset(this.dataSource, {this.package}) |
| : dataSourceType = DataSourceType.asset, |
| formatHint = null, |
| super(VideoPlayerValue(duration: null)); |
| |
| /// Constructs a [VideoPlayerController] playing a video from obtained from |
| /// the network. |
| /// |
| /// The URI for the video is given by the [dataSource] argument and must not be |
| /// null. |
| /// **Android only**: The [formatHint] option allows the caller to override |
| /// the video format detection code. |
| VideoPlayerController.network(this.dataSource, {this.formatHint}) |
| : dataSourceType = DataSourceType.network, |
| package = null, |
| super(VideoPlayerValue(duration: null)); |
| |
| /// Constructs a [VideoPlayerController] playing a video from a file. |
| /// |
| /// This will load the file from the file-URI given by: |
| /// `'file://${file.path}'`. |
| VideoPlayerController.file(File file) |
| : dataSource = 'file://${file.path}', |
| dataSourceType = DataSourceType.file, |
| package = null, |
| formatHint = null, |
| super(VideoPlayerValue(duration: null)); |
| |
| int _textureId; |
| final String dataSource; |
| final VideoFormat formatHint; |
| |
| /// Describes the type of data source this [VideoPlayerController] |
| /// is constructed with. |
| final DataSourceType dataSourceType; |
| |
| final String package; |
| Timer _timer; |
| bool _isDisposed = false; |
| Completer<void> _creatingCompleter; |
| StreamSubscription<dynamic> _eventSubscription; |
| _VideoAppLifeCycleObserver _lifeCycleObserver; |
| |
| @visibleForTesting |
| int get textureId => _textureId; |
| |
| Future<void> initialize() async { |
| _lifeCycleObserver = _VideoAppLifeCycleObserver(this); |
| _lifeCycleObserver.initialize(); |
| _creatingCompleter = Completer<void>(); |
| Map<dynamic, dynamic> dataSourceDescription; |
| switch (dataSourceType) { |
| case DataSourceType.asset: |
| dataSourceDescription = <String, dynamic>{ |
| 'asset': dataSource, |
| 'package': package |
| }; |
| break; |
| case DataSourceType.network: |
| dataSourceDescription = <String, dynamic>{ |
| 'uri': dataSource, |
| 'formatHint': _videoFormatStringMap[formatHint] |
| }; |
| break; |
| case DataSourceType.file: |
| dataSourceDescription = <String, dynamic>{'uri': dataSource}; |
| break; |
| } |
| final Map<String, dynamic> response = |
| await _channel.invokeMapMethod<String, dynamic>( |
| 'create', |
| dataSourceDescription, |
| ); |
| _textureId = response['textureId']; |
| _creatingCompleter.complete(null); |
| final Completer<void> initializingCompleter = Completer<void>(); |
| |
| DurationRange toDurationRange(dynamic value) { |
| final List<dynamic> pair = value; |
| return DurationRange( |
| Duration(milliseconds: pair[0]), |
| Duration(milliseconds: pair[1]), |
| ); |
| } |
| |
| void eventListener(dynamic event) { |
| if (_isDisposed) { |
| return; |
| } |
| |
| final Map<dynamic, dynamic> map = event; |
| switch (map['event']) { |
| case 'initialized': |
| value = value.copyWith( |
| duration: Duration(milliseconds: map['duration']), |
| size: Size(map['width']?.toDouble() ?? 0.0, |
| map['height']?.toDouble() ?? 0.0), |
| ); |
| initializingCompleter.complete(null); |
| _applyLooping(); |
| _applyVolume(); |
| _applyPlayPause(); |
| break; |
| case 'completed': |
| value = value.copyWith(isPlaying: false, position: value.duration); |
| _timer?.cancel(); |
| break; |
| case 'bufferingUpdate': |
| final List<dynamic> values = map['values']; |
| value = value.copyWith( |
| buffered: values.map<DurationRange>(toDurationRange).toList(), |
| ); |
| break; |
| case 'bufferingStart': |
| value = value.copyWith(isBuffering: true); |
| break; |
| case 'bufferingEnd': |
| value = value.copyWith(isBuffering: false); |
| break; |
| } |
| } |
| |
| void errorListener(Object obj) { |
| final PlatformException e = obj; |
| value = VideoPlayerValue.erroneous(e.message); |
| _timer?.cancel(); |
| } |
| |
| _eventSubscription = _eventChannelFor(_textureId) |
| .receiveBroadcastStream() |
| .listen(eventListener, onError: errorListener); |
| return initializingCompleter.future; |
| } |
| |
| EventChannel _eventChannelFor(int textureId) { |
| return EventChannel('flutter.io/videoPlayer/videoEvents$textureId'); |
| } |
| |
| @override |
| Future<void> dispose() async { |
| if (_creatingCompleter != null) { |
| await _creatingCompleter.future; |
| if (!_isDisposed) { |
| _isDisposed = true; |
| _timer?.cancel(); |
| await _eventSubscription?.cancel(); |
| await _channel.invokeMethod<void>( |
| 'dispose', |
| <String, dynamic>{'textureId': _textureId}, |
| ); |
| } |
| _lifeCycleObserver.dispose(); |
| } |
| _isDisposed = true; |
| super.dispose(); |
| } |
| |
| Future<void> play() async { |
| value = value.copyWith(isPlaying: true); |
| await _applyPlayPause(); |
| } |
| |
| Future<void> setLooping(bool looping) async { |
| value = value.copyWith(isLooping: looping); |
| await _applyLooping(); |
| } |
| |
| Future<void> pause() async { |
| value = value.copyWith(isPlaying: false); |
| await _applyPlayPause(); |
| } |
| |
| Future<void> _applyLooping() async { |
| if (!value.initialized || _isDisposed) { |
| return; |
| } |
| _channel.invokeMethod<void>( |
| 'setLooping', |
| <String, dynamic>{'textureId': _textureId, 'looping': value.isLooping}, |
| ); |
| } |
| |
| Future<void> _applyPlayPause() async { |
| if (!value.initialized || _isDisposed) { |
| return; |
| } |
| if (value.isPlaying) { |
| await _channel.invokeMethod<void>( |
| 'play', |
| <String, dynamic>{'textureId': _textureId}, |
| ); |
| _timer = Timer.periodic( |
| const Duration(milliseconds: 500), |
| (Timer timer) async { |
| if (_isDisposed) { |
| return; |
| } |
| final Duration newPosition = await position; |
| if (_isDisposed) { |
| return; |
| } |
| value = value.copyWith(position: newPosition); |
| }, |
| ); |
| } else { |
| _timer?.cancel(); |
| await _channel.invokeMethod<void>( |
| 'pause', |
| <String, dynamic>{'textureId': _textureId}, |
| ); |
| } |
| } |
| |
| Future<void> _applyVolume() async { |
| if (!value.initialized || _isDisposed) { |
| return; |
| } |
| await _channel.invokeMethod<void>( |
| 'setVolume', |
| <String, dynamic>{'textureId': _textureId, 'volume': value.volume}, |
| ); |
| } |
| |
| /// The position in the current video. |
| Future<Duration> get position async { |
| if (_isDisposed) { |
| return null; |
| } |
| return Duration( |
| milliseconds: await _channel.invokeMethod<int>( |
| 'position', |
| <String, dynamic>{'textureId': _textureId}, |
| ), |
| ); |
| } |
| |
| Future<void> seekTo(Duration moment) async { |
| if (_isDisposed) { |
| return; |
| } |
| if (moment > value.duration) { |
| moment = value.duration; |
| } else if (moment < const Duration()) { |
| moment = const Duration(); |
| } |
| await _channel.invokeMethod<void>('seekTo', <String, dynamic>{ |
| 'textureId': _textureId, |
| 'location': moment.inMilliseconds, |
| }); |
| value = value.copyWith(position: moment); |
| } |
| |
| /// Sets the audio volume of [this]. |
| /// |
| /// [volume] indicates a value between 0.0 (silent) and 1.0 (full volume) on a |
| /// linear scale. |
| Future<void> setVolume(double volume) async { |
| value = value.copyWith(volume: volume.clamp(0.0, 1.0)); |
| await _applyVolume(); |
| } |
| |
| static const Map<VideoFormat, String> _videoFormatStringMap = |
| <VideoFormat, String>{ |
| VideoFormat.ss: 'ss', |
| VideoFormat.hls: 'hls', |
| VideoFormat.dash: 'dash', |
| VideoFormat.other: 'other', |
| }; |
| } |
| |
| class _VideoAppLifeCycleObserver extends Object with WidgetsBindingObserver { |
| _VideoAppLifeCycleObserver(this._controller); |
| |
| bool _wasPlayingBeforePause = false; |
| final VideoPlayerController _controller; |
| |
| void initialize() { |
| WidgetsBinding.instance.addObserver(this); |
| } |
| |
| @override |
| void didChangeAppLifecycleState(AppLifecycleState state) { |
| switch (state) { |
| case AppLifecycleState.paused: |
| _wasPlayingBeforePause = _controller.value.isPlaying; |
| _controller.pause(); |
| break; |
| case AppLifecycleState.resumed: |
| if (_wasPlayingBeforePause) { |
| _controller.play(); |
| } |
| break; |
| default: |
| } |
| } |
| |
| void dispose() { |
| WidgetsBinding.instance.removeObserver(this); |
| } |
| } |
| |
| /// Displays the video controlled by [controller]. |
| class VideoPlayer extends StatefulWidget { |
| VideoPlayer(this.controller); |
| |
| final VideoPlayerController controller; |
| |
| @override |
| _VideoPlayerState createState() => _VideoPlayerState(); |
| } |
| |
| class _VideoPlayerState extends State<VideoPlayer> { |
| _VideoPlayerState() { |
| _listener = () { |
| final int newTextureId = widget.controller.textureId; |
| if (newTextureId != _textureId) { |
| setState(() { |
| _textureId = newTextureId; |
| }); |
| } |
| }; |
| } |
| |
| VoidCallback _listener; |
| int _textureId; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _textureId = widget.controller.textureId; |
| // Need to listen for initialization events since the actual texture ID |
| // becomes available after asynchronous initialization finishes. |
| widget.controller.addListener(_listener); |
| } |
| |
| @override |
| void didUpdateWidget(VideoPlayer oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| oldWidget.controller.removeListener(_listener); |
| _textureId = widget.controller.textureId; |
| widget.controller.addListener(_listener); |
| } |
| |
| @override |
| void deactivate() { |
| super.deactivate(); |
| widget.controller.removeListener(_listener); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return _textureId == null ? Container() : Texture(textureId: _textureId); |
| } |
| } |
| |
| class VideoProgressColors { |
| 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), |
| }); |
| |
| final Color playedColor; |
| final Color bufferedColor; |
| final Color backgroundColor; |
| } |
| |
| class _VideoScrubber extends StatefulWidget { |
| _VideoScrubber({ |
| @required this.child, |
| @required this.controller, |
| }); |
| |
| final Widget child; |
| final VideoPlayerController controller; |
| |
| @override |
| _VideoScrubberState createState() => _VideoScrubberState(); |
| } |
| |
| class _VideoScrubberState extends State<_VideoScrubber> { |
| bool _controllerWasPlaying = false; |
| |
| VideoPlayerController get controller => widget.controller; |
| |
| @override |
| Widget build(BuildContext context) { |
| void seekToRelativePosition(Offset globalPosition) { |
| final RenderBox box = context.findRenderObject(); |
| final Offset tapPos = box.globalToLocal(globalPosition); |
| final double relative = tapPos.dx / box.size.width; |
| final Duration position = controller.value.duration * relative; |
| controller.seekTo(position); |
| } |
| |
| return GestureDetector( |
| behavior: HitTestBehavior.opaque, |
| child: widget.child, |
| onHorizontalDragStart: (DragStartDetails details) { |
| if (!controller.value.initialized) { |
| return; |
| } |
| _controllerWasPlaying = controller.value.isPlaying; |
| if (_controllerWasPlaying) { |
| controller.pause(); |
| } |
| }, |
| onHorizontalDragUpdate: (DragUpdateDetails details) { |
| if (!controller.value.initialized) { |
| return; |
| } |
| seekToRelativePosition(details.globalPosition); |
| }, |
| onHorizontalDragEnd: (DragEndDetails details) { |
| if (_controllerWasPlaying) { |
| controller.play(); |
| } |
| }, |
| onTapDown: (TapDownDetails details) { |
| if (!controller.value.initialized) { |
| return; |
| } |
| seekToRelativePosition(details.globalPosition); |
| }, |
| ); |
| } |
| } |
| |
| /// Displays the play/buffering status of the video controlled by [controller]. |
| /// |
| /// If [allowScrubbing] is true, this widget will detect taps and drags and |
| /// seek the video accordingly. |
| /// |
| /// [padding] allows to specify some extra padding around the progress indicator |
| /// that will also detect the gestures. |
| class VideoProgressIndicator extends StatefulWidget { |
| VideoProgressIndicator( |
| this.controller, { |
| VideoProgressColors colors, |
| this.allowScrubbing, |
| this.padding = const EdgeInsets.only(top: 5.0), |
| }) : colors = colors ?? VideoProgressColors(); |
| |
| final VideoPlayerController controller; |
| final VideoProgressColors colors; |
| final bool allowScrubbing; |
| final EdgeInsets padding; |
| |
| @override |
| _VideoProgressIndicatorState createState() => _VideoProgressIndicatorState(); |
| } |
| |
| class _VideoProgressIndicatorState extends State<VideoProgressIndicator> { |
| _VideoProgressIndicatorState() { |
| listener = () { |
| if (!mounted) { |
| return; |
| } |
| setState(() {}); |
| }; |
| } |
| |
| VoidCallback listener; |
| |
| VideoPlayerController get controller => widget.controller; |
| |
| VideoProgressColors get colors => widget.colors; |
| |
| @override |
| void initState() { |
| super.initState(); |
| controller.addListener(listener); |
| } |
| |
| @override |
| void deactivate() { |
| controller.removeListener(listener); |
| super.deactivate(); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| Widget progressIndicator; |
| if (controller.value.initialized) { |
| final int duration = controller.value.duration.inMilliseconds; |
| final int position = controller.value.position.inMilliseconds; |
| |
| int maxBuffering = 0; |
| for (DurationRange range in controller.value.buffered) { |
| final int end = range.end.inMilliseconds; |
| if (end > maxBuffering) { |
| maxBuffering = end; |
| } |
| } |
| |
| progressIndicator = Stack( |
| fit: StackFit.passthrough, |
| children: <Widget>[ |
| LinearProgressIndicator( |
| value: maxBuffering / duration, |
| valueColor: AlwaysStoppedAnimation<Color>(colors.bufferedColor), |
| backgroundColor: colors.backgroundColor, |
| ), |
| LinearProgressIndicator( |
| value: position / duration, |
| valueColor: AlwaysStoppedAnimation<Color>(colors.playedColor), |
| backgroundColor: Colors.transparent, |
| ), |
| ], |
| ); |
| } else { |
| progressIndicator = LinearProgressIndicator( |
| value: null, |
| valueColor: AlwaysStoppedAnimation<Color>(colors.playedColor), |
| backgroundColor: colors.backgroundColor, |
| ); |
| } |
| final Widget paddedProgressIndicator = Padding( |
| padding: widget.padding, |
| child: progressIndicator, |
| ); |
| if (widget.allowScrubbing) { |
| return _VideoScrubber( |
| child: paddedProgressIndicator, |
| controller: controller, |
| ); |
| } else { |
| return paddedProgressIndicator; |
| } |
| } |
| } |