Add ability to play videos

parent 0df1a1ff
......@@ -31,7 +31,7 @@ import 'resources/intl/localizations.dart';
import 'resources/theme.dart';
import 'section/main/chat/page.dart';
import 'section/main/chat/image/page.dart';
import 'section/main/chat/media//page.dart';
import 'section/main/chat/settings/page.dart';
import 'section/main/chats/page.dart';
import 'section/main/chats/new/page.dart';
......@@ -87,7 +87,7 @@ final Map<String, MaterialPageRoute Function(Object)> routes = {
),
Routes.image: (dynamic arguments) => MaterialPageRoute(
settings: RouteSettings(name: Routes.image),
builder: (context) => ImagePage.withBloc(arguments[0], arguments[1])),
builder: (context) => MediaPage.withBloc(arguments[0], arguments[1])),
Routes.login: (params) => MaterialPageRoute(
settings: RouteSettings(name: Routes.login),
builder: (context) => StartPage.withBloc(),
......
......@@ -28,13 +28,13 @@ import 'state.dart';
export 'state.dart';
export 'event.dart';
class ImageBloc extends Bloc<ImageEvent, ImageState> {
class MediaBloc extends Bloc<MediaEvent, MediaState> {
final Matrix _matrix;
Room _room;
StreamSubscription _sub;
ImageBloc(this._matrix, RoomId roomId) : _room = _matrix.user.rooms[roomId] {
MediaBloc(this._matrix, RoomId roomId) : _room = _matrix.user.rooms[roomId] {
_sub = _room.updates.listen((update) {
_room = update.user.rooms[_room.id];
add(FetchImages());
......@@ -42,25 +42,25 @@ class ImageBloc extends Bloc<ImageEvent, ImageState> {
}
@override
ImageState get initialState => _loadImages();
MediaState get initialState => _loadImages();
@override
Stream<ImageState> mapEventToState(ImageEvent event) async* {
Stream<MediaState> mapEventToState(MediaEvent event) async* {
if (event is FetchImages) {
yield _loadImages();
}
}
ImageState _loadImages() {
final imageMessageEvents = <ImageMessageEvent>[];
MediaState _loadImages() {
final imageMessageEvents = <RoomEvent>[];
for (final event in _room.timeline) {
if (event is ImageMessageEvent) {
if (event is ImageMessageEvent || event is VideoMessageEvent) {
imageMessageEvents.add(event);
}
}
return ImageState(
return MediaState(
imageMessageEvents
.map(
(i) => ChatMessage(
......
import 'package:equatable/equatable.dart';
abstract class ImageEvent extends Equatable {
abstract class MediaEvent extends Equatable {
@override
List<Object> get props => [];
}
class FetchImages extends ImageEvent {}
class FetchImages extends MediaEvent {}
......@@ -21,50 +21,67 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import '../../../../models/chat_message.dart';
import '../../../../matrix.dart';
import 'widgets/video_player.dart';
import '../../../../util/date_format.dart';
import '../util/image_provider.dart';
import 'bloc.dart';
class ImagePage extends StatefulWidget {
class MediaPage extends StatefulWidget {
final EventId eventId;
ImagePage._(this.eventId);
MediaPage._(this.eventId);
static Widget withBloc(RoomId roomId, EventId eventId) {
return BlocProvider<ImageBloc>(
create: (c) => ImageBloc(Matrix.of(c), roomId),
child: ImagePage._(eventId),
return BlocProvider<MediaBloc>(
create: (c) => MediaBloc(Matrix.of(c), roomId),
child: MediaPage._(eventId),
);
}
@override
State<StatefulWidget> createState() => _ImagePageState();
State<StatefulWidget> createState() => _MediaPageState();
}
class _ImagePageState extends State<ImagePage> {
class _MediaPageState extends State<MediaPage> {
PageController _controller;
ChatMessage _initial;
ChatMessage _current;
@override
void initState() {
super.initState();
bool _hasSwitchedToOther = false;
bool _appBarVisible = true;
void _showAppBar() {
setState(() {
_appBarVisible = true;
});
}
void _hideAppBar() {
setState(() {
_appBarVisible = false;
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final messages = BlocProvider.of<ImageBloc>(context).state.messages;
final messages = BlocProvider.of<MediaBloc>(context).state.messages;
_initial = messages.firstWhere((msg) => msg.event.id == widget.eventId);
_current = _initial;
// If the initial event is a video, hide the app bar because the video
// will auto play
_appBarVisible = _initial.event is! VideoMessageEvent;
}
@override
......@@ -72,52 +89,96 @@ class _ImagePageState extends State<ImagePage> {
return Scaffold(
backgroundColor: Colors.black,
extendBodyBehindAppBar: true,
appBar: AppBar(
title: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_current.sender.name),
SizedBox(height: 2),
Text(
'${formatAsDate(context, _current.event.time)},'
' ${formatAsTime(_current.event.time)}',
style: Theme.of(context)
.textTheme
.bodyText2
.copyWith(color: Colors.white),
),
],
appBar: _HideableAppBar(
visible: _appBarVisible,
child: AppBar(
title: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_current.sender.name),
SizedBox(height: 2),
Text(
'${formatAsDate(context, _current.event.time)},'
' ${formatAsTime(_current.event.time)}',
style: Theme.of(context)
.textTheme
.bodyText2
.copyWith(color: Colors.white),
),
],
),
backgroundColor: Color(0x64000000),
),
backgroundColor: Color(0x64000000),
),
body: BlocBuilder<ImageBloc, ImageState>(builder: (context, state) {
body: BlocBuilder<MediaBloc, MediaState>(builder: (context, state) {
final messages = state.messages;
return PhotoViewGallery.builder(
_controller ??= PageController(
initialPage: state.messages.indexOf(_current),
);
return PageView.builder(
controller: _controller,
itemCount: messages.length,
reverse: true,
builder: (context, index) {
final event = messages[index].event as ImageMessageEvent;
return PhotoViewGalleryPageOptions(
imageProvider: imageProvider(
context: context,
url: event.content.url,
),
minScale: PhotoViewComputedScale.contained,
);
itemBuilder: (context, index) {
final event = messages[index].event;
if (event is ImageMessageEvent) {
return PhotoView(
imageProvider: imageProvider(
context: context,
url: event.content.url,
),
minScale: PhotoViewComputedScale.contained,
);
} else if (event is VideoMessageEvent) {
return VideoPlayer(
event: event,
autoPlay: event.equals(_initial.event) &&
!_hasSwitchedToOther &&
_current.event.equals(_initial.event),
onControlsShown: _showAppBar,
onControlsHidden: _hideAppBar,
);
} else {
return Container();
}
},
onPageChanged: (index) {
setState(() {
_current = messages[index];
_appBarVisible = true;
_hasSwitchedToOther = true;
});
},
pageController: PageController(
initialPage: messages.indexOf(_current),
),
);
}),
);
}
}
class _HideableAppBar extends StatelessWidget implements PreferredSizeWidget {
final bool visible;
final Widget child;
const _HideableAppBar({
Key key,
@required this.child,
this.visible = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: Duration(milliseconds: 300),
switchInCurve: Curves.decelerate,
switchOutCurve: Curves.decelerate.flipped,
child: visible ? child : Container(),
);
}
@override
Size get preferredSize => Size(double.infinity, kToolbarHeight);
}
......@@ -2,10 +2,10 @@ import 'package:equatable/equatable.dart';
import '../../../../models/chat_message.dart';
class ImageState extends Equatable {
class MediaState extends Equatable {
final List<ChatMessage> messages;
ImageState(this.messages);
MediaState(this.messages);
@override
List<Object> get props => [messages];
......
// Copyright (C) 2020 Wilko Manger
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:video_player/video_player.dart' as flutter;
import '../../widgets/video_button.dart';
import '../../../../../util/url.dart';
class VideoPlayer extends StatefulWidget {
/// If [autoPlay] is true, controls will be hidden immediately.
final bool autoPlay;
final VideoMessageEvent event;
final VoidCallback onControlsShown;
final VoidCallback onControlsHidden;
const VideoPlayer({
Key key,
@required this.event,
this.autoPlay = false,
this.onControlsShown,
this.onControlsHidden,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _VideoPlayerState();
}
class _VideoPlayerState extends State<VideoPlayer> {
flutter.VideoPlayerController _controller;
bool _initialized = false;
bool _isPlaying = false;
double _aspectRatio;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
Timer _hideControlsTimer;
bool _controlsVisible;
Timer _loadingBarTimer;
bool _loadingBarVisible = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_controlsVisible = !widget.autoPlay;
final url = widget.event.content.url?.toHttps(context);
_loadingBarTimer = Timer(Duration(milliseconds: 500), () {
setState(() {
_loadingBarVisible = true;
});
});
DefaultCacheManager().getFileFromCache(url).then((info) {
if (info?.file == null) {
_controller = flutter.VideoPlayerController.network(url);
// Download the file for future use
// TODO: Find way to cache downloaded video via .network controller
DefaultCacheManager().downloadFile(url);
} else {
_controller = flutter.VideoPlayerController.file(info.file);
}
_controller.initialize().then(
(_) {
_loadingBarTimer?.cancel();
// Set state to show the first frame if we're not playing the
// video immediately, and to build the VideoPlayer widget
setState(() {
_initialized = true;
_duration = _controller.value.duration;
_loadingBarVisible = false;
});
if (widget.autoPlay) {
_togglePlay();
}
_controller.addListener(() {
if (_controller.value.position != _position ||
_isPlaying != _controller.value.isPlaying) {
setState(() {
_position = _controller.value.position;
_isPlaying = _controller.value.isPlaying;
// Done playing, show controls again
if (_position >= _duration) {
_controlsVisible = true;
widget?.onControlsShown?.call();
}
});
}
});
},
);
});
final info = widget.event.content.info;
final width = info?.width ?? info?.thumbnail?.width;
final height = info?.height ?? info?.thumbnail?.height;
if (width != null && height != null) {
_aspectRatio = width / height;
}
}
void _togglePlay() {
// Controller is not yet initialized, do nothing
if (_controller == null) {
return;
}
if (_isPlaying) {
_controller.pause();
} else {
if (_position >= _duration) {
_seekTo(0);
}
_controller.play();
_hideControlsEventually();
}
}
void _seekTo(double milliseconds) {
final wasPlaying = _isPlaying;
if (wasPlaying) {
_controller.pause();
}
_controller.seekTo(Duration(milliseconds: milliseconds.round()));
if (wasPlaying) {
_controller.play();
}
}
void _showControls() {
setState(() {
_controlsVisible = true;
widget.onControlsShown?.call();
});
_hideControlsEventually();
}
void _hideControlsEventually() {
_hideControlsTimer?.cancel();
_hideControlsTimer = Timer(Duration(seconds: 2), () {
if (mounted && _isPlaying) {
setState(() {
_controlsVisible = false;
widget.onControlsHidden?.call();
});
}
});
}
@override
Widget build(BuildContext context) {
final videoPlayer = _initialized
? flutter.VideoPlayer(_controller)
: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl:
widget.event.content.info.thumbnail?.url?.toHttps(context),
);
return Stack(
fit: StackFit.expand,
children: <Widget>[
if (_aspectRatio != null)
Center(
child: AspectRatio(
aspectRatio: _aspectRatio,
child: videoPlayer,
),
)
else
videoPlayer,
GestureDetector(
onTap: _showControls,
),
Center(
child: AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: !_loadingBarVisible
? _controlsVisible
? !_isPlaying
? PlayButton(onPressed: _togglePlay)
: PauseButton(onPressed: _togglePlay)
: Container()
: CircularProgressIndicator(),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSwitcher(
duration: Duration(milliseconds: 300),
switchInCurve: Curves.decelerate,
switchOutCurve: Curves.decelerate.flipped,
child: _controlsVisible
? Container(
key: ValueKey(_controlsVisible),
padding: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withOpacity(0.2),
Colors.black.withOpacity(0),
],
),
),
child: Row(
children: <Widget>[
_Time(_position),
Expanded(
child: Slider(
min: 0,
max: _duration.inMilliseconds.toDouble(),
value: _position.inMilliseconds.toDouble().clamp(
0.0,
_duration.inMilliseconds.toDouble(),
),
onChanged: _seekTo,
),
),
_Time(_duration),
],
),
)
: Container(key: ValueKey(_controlsVisible)),
),
),
],
);
}
@override
void dispose() {
super.dispose();
_controller.dispose();
}
}
class _Time extends StatelessWidget {
final Duration duration;
const _Time(this.duration, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final minutes = duration.inMinutes.toString().padLeft(2, '0');
final seconds =
(duration.inSeconds - (Duration.secondsPerMinute * duration.inMinutes))
.toString()
.padLeft(2, '0');
return Text(
'$minutes:$seconds',
style: TextStyle(
color: Colors.white,
),
);
}
}
......@@ -18,31 +18,35 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:provider/provider.dart';
import '../../../../image/page.dart';
import '../../../video_button.dart';
import '../../../../media/page.dart';
import '../../message.dart';
import '../../../../util/image_provider.dart';
/// Creates an [ImageContent] widget for a [MessageBubble].
/// Creates an [PictureContent] widget for a [MessageBubble].
///
/// Can be either for an `ImageMessageEvent` or a thumbnail for a
/// `VideoMessageEvent`.
///
/// Must have a [MessageBubble] ancestor.
class ImageContent extends StatefulWidget {
ImageContent({Key key}) : super(key: key);
class PictureContent extends StatefulWidget {
PictureContent({Key key}) : super(key: key);
@override
State<StatefulWidget> createState() => _ImageContentState();
State<StatefulWidget> createState() => _PictureContentState();
}
class _ImageContentState extends State<ImageContent> {
class _PictureContentState extends State<PictureContent> {
static const double _width = 256;
static const double _minHeight = 72;
static const double _maxHeight = 292;
bool get _isFile => _fileUri != null;
Uri _fileUri;
bool _isVideo;
Uri _uri;
double _height;
......@@ -51,24 +55,30 @@ class _ImageContentState extends State<ImageContent> {
super.didChangeDependencies();
final bubble = MessageBubble.of(context);
assert(bubble.message.event is ImageMessageEvent);
final event = bubble.message.event;
final event = bubble.message.event as ImageMessageEvent;
var height = 0, width = 0;
if (event is ImageMessageEvent) {
height = event.content.info?.height;
width = event.content.info?.width;
_height = (event.content.info?.height ??
0 / (event.content.info?.width ?? 0 / _width))
.clamp(_minHeight, _maxHeight)
.toDouble();
_uri = event.content.url;
_isVideo = false;
} else if (event is VideoMessageEvent) {
height = event.content.info?.thumbnail?.height ?? 0;
width = event.content.info?.thumbnail?.width ?? 0;
if (event.content.url.isScheme('file')) {
_fileUri = event.content.url;
_uri = event.content.info?.thumbnail?.url;
_isVideo = true;
}
_height =
(height / (width / _width)).clamp(_minHeight, _maxHeight).toDouble();
}
@override
Widget build(BuildContext context) {
final bubble = MessageBubble.of(context);
final event = bubble.message.event as ImageMessageEvent;
final content = OpenContainer(
tappable: false,
......@@ -92,7 +102,7 @@ class _ImageContentState extends State<ImageContent> {
child: Image(
image: imageProvider(
context: context,
url: _isFile ? _fileUri : event.content.url,
url: _uri,
),