blob: 0c9b8442bb0458225962da6da294348452abdcc4 [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 '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('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)';
}
/// The duration, current position, buffering state, error state and settings
/// of a [VideoPlayerController].
class VideoPlayerValue {
/// 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;
/// 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;
VideoPlayerValue({
@required this.duration,
this.size,
this.position: const Duration(),
this.buffered: const <DurationRange>[],
this.isPlaying: false,
this.isLooping: false,
this.volume: 1.0,
this.errorDescription,
});
VideoPlayerValue.uninitialized() : this(duration: null);
VideoPlayerValue.erroneous(String errorDescription)
: this(duration: null, errorDescription: errorDescription);
bool get initialized => duration != null;
bool get hasError => errorDescription != null;
double get aspectRatio => size.width / size.height;
VideoPlayerValue copyWith({
Duration duration,
Size size,
Duration position,
List<DurationRange> buffered,
bool isPlaying,
bool isLooping,
double volume,
String errorDescription,
}) {
return new 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,
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, '
'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> {
int _textureId;
final String uri;
Timer timer;
bool isDisposed = false;
Completer<Null> _creatingCompleter;
StreamSubscription<dynamic> _eventSubscription;
_VideoAppLifeCycleObserver _lifeCycleObserver;
VideoPlayerController(this.uri) : super(new VideoPlayerValue(duration: null));
Future<Null> initialize() async {
_lifeCycleObserver = new _VideoAppLifeCycleObserver(this);
_lifeCycleObserver.initialize();
_creatingCompleter = new Completer<Null>();
final Map<dynamic, dynamic> response = await _channel.invokeMethod(
'create',
<String, dynamic>{'dataSource': uri},
);
_textureId = response['textureId'];
_creatingCompleter.complete(null);
DurationRange toDurationRange(dynamic value) {
final List<dynamic> pair = value;
return new DurationRange(
new Duration(milliseconds: pair[0]),
new Duration(milliseconds: pair[1]),
);
}
void eventListener(dynamic event) {
final Map<dynamic, dynamic> map = event;
switch (map['event']) {
case 'initialized':
value = value.copyWith(
duration: new Duration(milliseconds: map['duration']),
size: new Size(map['width'].toDouble(), map['height'].toDouble()),
);
_applyLooping();
_applyVolume();
_applyPlayPause();
break;
case 'completed':
value = value.copyWith(isPlaying: false);
timer?.cancel();
break;
case 'bufferingUpdate':
final List<dynamic> values = map['values'];
value = value.copyWith(
buffered: values.map<DurationRange>(toDurationRange).toList(),
);
break;
}
}
void errorListener(Object obj) {
final PlatformException e = obj;
value = new VideoPlayerValue.erroneous(e.message);
timer?.cancel();
}
_eventSubscription = _eventChannelFor(_textureId)
.receiveBroadcastStream()
.listen(eventListener, onError: errorListener);
}
EventChannel _eventChannelFor(int textureId) {
return new EventChannel('flutter.io/videoPlayer/videoEvents$textureId');
}
@override
Future<Null> dispose() async {
if (_creatingCompleter != null) {
await _creatingCompleter.future;
if (!isDisposed) {
isDisposed = true;
timer?.cancel();
await _eventSubscription?.cancel();
await _channel.invokeMethod(
'dispose',
<String, dynamic>{'textureId': _textureId},
);
}
_lifeCycleObserver.dispose();
}
isDisposed = true;
super.dispose();
}
Future<Null> play() async {
value = value.copyWith(isPlaying: true);
await _applyPlayPause();
}
Future<Null> setLooping(bool looping) async {
value = value.copyWith(isLooping: looping);
await _applyLooping();
}
Future<Null> pause() async {
value = value.copyWith(isPlaying: false);
await _applyPlayPause();
}
Future<Null> _applyLooping() async {
if (!value.initialized || isDisposed) {
return;
}
_channel.invokeMethod(
'setLooping',
<String, dynamic>{'textureId': _textureId, 'looping': value.isLooping},
);
}
Future<Null> _applyPlayPause() async {
if (!value.initialized || isDisposed) {
return;
}
if (value.isPlaying) {
await _channel.invokeMethod(
'play',
<String, dynamic>{'textureId': _textureId},
);
timer = new 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(
'pause',
<String, dynamic>{'textureId': _textureId},
);
}
}
Future<Null> _applyVolume() async {
if (!value.initialized || isDisposed) {
return;
}
await _channel.invokeMethod(
'setVolume',
<String, dynamic>{'textureId': _textureId, 'volume': value.volume},
);
}
/// The position in the current video.
Future<Duration> get position async {
if (isDisposed) {
return null;
}
return new Duration(
milliseconds: await _channel.invokeMethod(
'position',
<String, dynamic>{'textureId': _textureId},
),
);
}
Future<Null> 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('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<Null> setVolume(double volume) async {
value = value.copyWith(volume: volume.clamp(0.0, 1.0));
await _applyVolume();
}
}
class _VideoAppLifeCycleObserver extends WidgetsBindingObserver {
bool _wasPlayingBeforePause = false;
final VideoPlayerController _controller;
_VideoAppLifeCycleObserver(this._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 StatelessWidget {
final VideoPlayerController controller;
VideoPlayer(this.controller);
@override
Widget build(BuildContext context) {
return controller._textureId == null
? new Container()
: new Texture(textureId: controller._textureId);
}
}
class VideoProgressColors {
final Color playedColor;
final Color bufferedColor;
final Color backgroundColor;
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),
});
}
class _VideoScrubber extends StatefulWidget {
final Widget child;
final VideoPlayerController controller;
_VideoScrubber({
@required this.child,
@required this.controller,
});
@override
_VideoScrubberState createState() => new _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 new 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 {
final VideoPlayerController controller;
final VideoProgressColors colors;
final bool allowScrubbing;
final EdgeInsets padding;
VideoProgressIndicator(
this.controller, {
VideoProgressColors colors,
this.allowScrubbing,
this.padding: const EdgeInsets.only(top: 5.0),
})
: colors = colors ?? new VideoProgressColors();
@override
_VideoProgressIndicatorState createState() =>
new _VideoProgressIndicatorState();
}
class _VideoProgressIndicatorState extends State<VideoProgressIndicator> {
VoidCallback listener;
_VideoProgressIndicatorState() {
listener = () {
if (!mounted) {
return;
}
setState(() {});
};
}
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 = new Stack(
fit: StackFit.passthrough,
children: <Widget>[
new LinearProgressIndicator(
value: maxBuffering / duration,
valueColor: new AlwaysStoppedAnimation<Color>(colors.bufferedColor),
backgroundColor: colors.backgroundColor,
),
new LinearProgressIndicator(
value: position / duration,
valueColor: new AlwaysStoppedAnimation<Color>(colors.playedColor),
backgroundColor: Colors.transparent,
),
],
);
} else {
progressIndicator = new LinearProgressIndicator(
value: null,
valueColor: new AlwaysStoppedAnimation<Color>(colors.playedColor),
backgroundColor: colors.backgroundColor,
);
}
final Widget paddedProgressIndicator = new Padding(
padding: widget.padding,
child: progressIndicator,
);
if (widget.allowScrubbing) {
return new _VideoScrubber(
child: paddedProgressIndicator,
controller: controller,
);
} else {
return paddedProgressIndicator;
}
}
}