blob: 97b87b20946dfe6ab47a6e45930b0be2bbbc39cf [file] [log] [blame]
// 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/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
export 'package:video_player_platform_interface/video_player_platform_interface.dart'
show DurationRange, DataSourceType, VideoFormat;
export 'src/closed_caption_file.dart';
final VideoPlayerPlatform _videoPlayerPlatform = VideoPlayerPlatform.instance
// This will clear all open videos on the platform when a full restart is
// performed.
..init();
/// 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,
this.position = const Duration(),
this.buffered = const <DurationRange>[],
this.isPlaying = false,
this.isLooping = false,
this.isBuffering = false,
this.volume = 1.0,
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);
/// 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;
/// 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,
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)';
}
}
/// 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;
/// 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;
Completer<void> _creatingCompleter;
StreamSubscription<dynamic> _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<void> initialize() async {
_lifeCycleObserver = _VideoAppLifeCycleObserver(this);
_lifeCycleObserver.initialize();
_creatingCompleter = Completer<void>();
DataSource dataSourceDescription;
switch (dataSourceType) {
case DataSourceType.asset:
dataSourceDescription = DataSource(
sourceType: DataSourceType.asset,
asset: dataSource,
package: package,
);
break;
case DataSourceType.network:
dataSourceDescription = DataSource(
sourceType: DataSourceType.network,
uri: dataSource,
formatHint: formatHint,
);
break;
case DataSourceType.file:
dataSourceDescription = DataSource(
sourceType: DataSourceType.file,
uri: dataSource,
);
break;
}
_textureId = await _videoPlayerPlatform.create(dataSourceDescription);
_creatingCompleter.complete(null);
final Completer<void> initializingCompleter = Completer<void>();
void eventListener(VideoEvent event) {
if (_isDisposed) {
return;
}
switch (event.eventType) {
case VideoEventType.initialized:
value = value.copyWith(
duration: event.duration,
size: event.size,
);
initializingCompleter.complete(null);
_applyLooping();
_applyVolume();
_applyPlayPause();
break;
case VideoEventType.completed:
value = value.copyWith(isPlaying: false, position: value.duration);
_timer?.cancel();
break;
case VideoEventType.bufferingUpdate:
value = value.copyWith(buffered: event.buffered);
break;
case VideoEventType.bufferingStart:
value = value.copyWith(isBuffering: true);
break;
case VideoEventType.bufferingEnd:
value = value.copyWith(isBuffering: false);
break;
case VideoEventType.unknown:
break;
}
}
void errorListener(Object obj) {
final PlatformException e = obj;
value = VideoPlayerValue.erroneous(e.message);
_timer?.cancel();
if (!initializingCompleter.isCompleted) {
initializingCompleter.completeError(obj);
}
}
_eventSubscription = _videoPlayerPlatform
.videoEventsFor(_textureId)
.listen(eventListener, onError: errorListener);
return initializingCompleter.future;
}
@override
Future<void> dispose() async {
if (_creatingCompleter != null) {
await _creatingCompleter.future;
if (!_isDisposed) {
_isDisposed = true;
_timer?.cancel();
await _eventSubscription?.cancel();
await _videoPlayerPlatform.dispose(_textureId);
}
_lifeCycleObserver.dispose();
}
_isDisposed = true;
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<void> 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<void> setLooping(bool looping) async {
value = value.copyWith(isLooping: looping);
await _applyLooping();
}
/// Pauses the video.
Future<void> pause() async {
value = value.copyWith(isPlaying: false);
await _applyPlayPause();
}
Future<void> _applyLooping() async {
if (!value.initialized || _isDisposed) {
return;
}
await _videoPlayerPlatform.setLooping(_textureId, value.isLooping);
}
Future<void> _applyPlayPause() async {
if (!value.initialized || _isDisposed) {
return;
}
if (value.isPlaying) {
await _videoPlayerPlatform.play(_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 _videoPlayerPlatform.pause(_textureId);
}
}
Future<void> _applyVolume() async {
if (!value.initialized || _isDisposed) {
return;
}
await _videoPlayerPlatform.setVolume(_textureId, value.volume);
}
/// The position in the current video.
Future<Duration> get position async {
if (_isDisposed) {
return null;
}
return await _videoPlayerPlatform.getPosition(_textureId);
}
/// 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<void> seekTo(Duration position) async {
if (_isDisposed) {
return;
}
if (position > value.duration) {
position = value.duration;
} else if (position < const Duration()) {
position = const Duration();
}
await _videoPlayerPlatform.seekTo(_textureId, position);
value = value.copyWith(position: position);
}
/// 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();
}
}
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);
}
}
/// 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
_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()
: _videoPlayerPlatform.buildView(_textureId);
}
}
/// 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;
}
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 {
/// 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,
this.allowScrubbing,
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
_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;
}
}
}