custom video control overlay

This commit is contained in:
Thibault Deckers 2019-10-06 16:30:06 +09:00
parent 6d12f3ddaa
commit c4d44b49ea
8 changed files with 341 additions and 64 deletions

View file

@ -4,6 +4,7 @@ import 'package:aves/model/image_file_service.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_service.dart'; import 'package:aves/model/metadata_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/date_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:geocoder/geocoder.dart'; import 'package:geocoder/geocoder.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
@ -124,20 +125,7 @@ class ImageEntry {
return d == null ? null : DateTime(d.year, d.month); return d == null ? null : DateTime(d.year, d.month);
} }
String get durationText { String get durationText => formatDuration(Duration(milliseconds: durationMillis));
final d = Duration(milliseconds: durationMillis);
String twoDigits(int n) {
if (n >= 10) return '$n';
return '0$n';
}
String twoDigitSeconds = twoDigits(d.inSeconds.remainder(Duration.secondsPerMinute));
if (d.inHours == 0) return '${d.inMinutes}:$twoDigitSeconds';
String twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour));
return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds';
}
bool get hasGps => isCatalogued && catalogMetadata.latitude != null; bool get hasGps => isCatalogued && catalogMetadata.latitude != null;

View file

@ -9,3 +9,16 @@ bool isToday(DateTime d) => isAtSameDayAs(d, DateTime.now());
bool isThisMonth(DateTime d) => isAtSameMonthAs(d, DateTime.now()); bool isThisMonth(DateTime d) => isAtSameMonthAs(d, DateTime.now());
bool isThisYear(DateTime d) => isAtSameYearAs(d, DateTime.now()); bool isThisYear(DateTime d) => isAtSameYearAs(d, DateTime.now());
String formatDuration(Duration d) {
String twoDigits(int n) {
if (n >= 10) return '$n';
return '0$n';
}
String twoDigitSeconds = twoDigits(d.inSeconds.remainder(Duration.secondsPerMinute));
if (d.inHours == 0) return '${d.inMinutes}:$twoDigitSeconds';
String twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour));
return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds';
}

View file

@ -18,6 +18,24 @@ class BlurredRect extends StatelessWidget {
} }
} }
class BlurredRRect extends StatelessWidget {
final double borderRadius;
final Widget child;
const BlurredRRect({Key key, this.borderRadius, this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
child: child,
),
);
}
}
class BlurredOval extends StatelessWidget { class BlurredOval extends StatelessWidget {
final Widget child; final Widget child;

View file

@ -7,6 +7,7 @@ import 'package:aves/utils/android_app_service.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/overlay_bottom.dart'; import 'package:aves/widgets/fullscreen/overlay_bottom.dart';
import 'package:aves/widgets/fullscreen/overlay_top.dart'; import 'package:aves/widgets/fullscreen/overlay_top.dart';
import 'package:aves/widgets/fullscreen/overlay_video.dart';
import 'package:aves/widgets/fullscreen/video.dart'; import 'package:aves/widgets/fullscreen/video.dart';
import 'package:flushbar/flushbar.dart'; import 'package:flushbar/flushbar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -17,6 +18,8 @@ import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart'; import 'package:photo_view/photo_view_gallery.dart';
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
import 'package:screen/screen.dart'; import 'package:screen/screen.dart';
import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart';
class FullscreenPage extends AnimatedWidget { class FullscreenPage extends AnimatedWidget {
final ImageCollection collection; final ImageCollection collection;
@ -92,6 +95,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
Animation<double> _topOverlayScale; Animation<double> _topOverlayScale;
Animation<Offset> _bottomOverlayOffset; Animation<Offset> _bottomOverlayOffset;
EdgeInsets _frozenViewInsets, _frozenViewPadding; EdgeInsets _frozenViewInsets, _frozenViewPadding;
List<Tuple2<String, VideoPlayerController>> _videoControllers = List();
ImageCollection get collection => widget.collection; ImageCollection get collection => widget.collection;
@ -117,6 +121,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
curve: Curves.easeOutQuart, curve: Curves.easeOutQuart,
)); ));
_overlayVisible.addListener(onOverlayVisibleChange); _overlayVisible.addListener(onOverlayVisibleChange);
initVideoController();
Screen.keepOn(true); Screen.keepOn(true);
initOverlay(); initOverlay();
@ -132,12 +137,13 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
@override @override
void dispose() { void dispose() {
_overlayVisible.removeListener(onOverlayVisibleChange); _overlayVisible.removeListener(onOverlayVisibleChange);
_videoControllers.forEach((kv) => kv.item2.dispose());
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final entry = _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
return WillPopScope( return WillPopScope(
onWillPop: () { onWillPop: () {
if (_currentVerticalPage == 1) { if (_currentVerticalPage == 1) {
@ -160,8 +166,9 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
collection: collection, collection: collection,
pageController: _horizontalPager, pageController: _horizontalPager,
onTap: () => _overlayVisible.value = !_overlayVisible.value, onTap: () => _overlayVisible.value = !_overlayVisible.value,
onPageChanged: (page) => setState(() => _currentHorizontalPage = page), onPageChanged: onHorizontalPageChanged,
onScaleChanged: (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial), onScaleChanged: (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial),
videoControllers: _videoControllers,
), ),
NotificationListener( NotificationListener(
onNotification: (notification) { onNotification: (notification) {
@ -172,33 +179,51 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
), ),
], ],
), ),
if (_currentHorizontalPage != null && _currentVerticalPage == 0) ...[ ..._buildOverlay(entry)
FullscreenTopOverlay(
entries: entries,
index: _currentHorizontalPage,
scale: _topOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
onActionSelected: (action) => onActionSelected(entry, action),
),
Positioned(
bottom: 0,
child: SlideTransition(
position: _bottomOverlayOffset,
child: FullscreenBottomOverlay(
entries: entries,
index: _currentHorizontalPage,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
),
)
]
], ],
), ),
); );
} }
List<Widget> _buildOverlay(ImageEntry entry) {
if (entry == null || _currentVerticalPage != 0) return [];
final videoController = entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2 : null;
return [
FullscreenTopOverlay(
entries: entries,
index: _currentHorizontalPage,
scale: _topOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
onActionSelected: (action) => onActionSelected(entry, action),
),
Positioned(
bottom: 0,
child: Column(
children: [
if (videoController != null)
VideoControlOverlay(
entry: entry,
controller: videoController,
scale: _topOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
SlideTransition(
position: _bottomOverlayOffset,
child: FullscreenBottomOverlay(
entries: entries,
index: _currentHorizontalPage,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
),
],
),
)
];
}
goToVerticalPage(int page) { goToVerticalPage(int page) {
return _verticalPager.animateToPage( return _verticalPager.animateToPage(
page, page,
@ -344,6 +369,30 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
if (newName == null || newName.isEmpty) return; if (newName == null || newName.isEmpty) return;
showFeedback(await entry.rename(newName) ? 'Done!' : 'Failed'); showFeedback(await entry.rename(newName) ? 'Done!' : 'Failed');
} }
onHorizontalPageChanged(int page) {
_currentHorizontalPage = page;
initVideoController();
setState(() {});
}
initVideoController() {
final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (entry == null || !entry.isVideo) return;
final path = entry.path;
var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null);
if (controllerEntry != null) {
_videoControllers.remove(controllerEntry);
} else {
final controller = VideoPlayerController.file(File(path))..initialize();
controllerEntry = Tuple2(path, controller);
}
_videoControllers.insert(0, controllerEntry);
while (_videoControllers.length > 3) {
_videoControllers.removeLast().item2.dispose();
}
}
} }
enum FullscreenAction { delete, edit, info, open, openMap, print, rename, rotateCCW, rotateCW, setAs, share } enum FullscreenAction { delete, edit, info, open, openMap, print, rename, rotateCCW, rotateCW, setAs, share }
@ -354,6 +403,7 @@ class ImagePage extends StatefulWidget {
final VoidCallback onTap; final VoidCallback onTap;
final ValueChanged<int> onPageChanged; final ValueChanged<int> onPageChanged;
final ValueChanged<PhotoViewScaleState> onScaleChanged; final ValueChanged<PhotoViewScaleState> onScaleChanged;
final List<Tuple2<String, VideoPlayerController>> videoControllers;
const ImagePage({ const ImagePage({
this.collection, this.collection,
@ -361,6 +411,7 @@ class ImagePage extends StatefulWidget {
this.onTap, this.onTap,
this.onPageChanged, this.onPageChanged,
this.onScaleChanged, this.onScaleChanged,
this.videoControllers,
}); });
@override @override
@ -378,10 +429,19 @@ class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin
builder: (galleryContext, index) { builder: (galleryContext, index) {
final entry = entries[index]; final entry = entries[index];
if (entry.isVideo) { if (entry.isVideo) {
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2;
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(
child: AvesVideo(entry: entry), child: videoController != null
? AvesVideo(
entry: entry,
controller: videoController,
)
: SizedBox(),
childSize: MediaQuery.of(galleryContext).size, childSize: MediaQuery.of(galleryContext).size,
// no heroTag because `Chewie` already internally builds one with the videoController heroAttributes: PhotoViewHeroAttributes(
tag: entry.uri,
transitionOnUserGestures: true,
),
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => widget.onTap?.call(), onTapUp: (tapContext, details, value) => widget.onTap?.call(),

View file

@ -16,8 +16,8 @@ class FullscreenBottomOverlay extends StatefulWidget {
const FullscreenBottomOverlay({ const FullscreenBottomOverlay({
Key key, Key key,
this.entries, @required this.entries,
this.index, @required this.index,
this.viewInsets, this.viewInsets,
this.viewPadding, this.viewPadding,
}) : super(key: key); }) : super(key: key);

View file

@ -15,9 +15,9 @@ class FullscreenTopOverlay extends StatelessWidget {
const FullscreenTopOverlay({ const FullscreenTopOverlay({
Key key, Key key,
this.entries, @required this.entries,
this.index, @required this.index,
this.scale, @required this.scale,
this.viewInsets, this.viewInsets,
this.viewPadding, this.viewPadding,
this.onActionSelected, this.onActionSelected,

View file

@ -0,0 +1,165 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/android_app_service.dart';
import 'package:aves/utils/date_utils.dart';
import 'package:aves/widgets/common/blurred.dart';
import 'package:aves/widgets/fullscreen/overlay_top.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
class VideoControlOverlay extends StatefulWidget {
final ImageEntry entry;
final Animation<double> scale;
final VideoPlayerController controller;
final EdgeInsets viewInsets, viewPadding;
const VideoControlOverlay({
Key key,
@required this.entry,
@required this.controller,
@required this.scale,
this.viewInsets,
this.viewPadding,
}) : super(key: key);
@override
State<StatefulWidget> createState() => VideoControlOverlayState();
}
class VideoControlOverlayState extends State<VideoControlOverlay> {
ImageEntry get entry => widget.entry;
Animation<double> get scale => widget.scale;
VideoPlayerController get controller => widget.controller;
VideoPlayerValue get value => widget.controller.value;
double get progress => value.position != null && value.duration != null ? value.position.inMilliseconds / value.duration.inMilliseconds : 0;
@override
void initState() {
super.initState();
registerWidget(widget);
_onValueChange();
}
@override
void didUpdateWidget(VideoControlOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
unregisterWidget(oldWidget);
registerWidget(widget);
}
@override
void dispose() {
unregisterWidget(widget);
super.dispose();
}
registerWidget(VideoControlOverlay widget) {
widget.controller.addListener(_onValueChange);
}
unregisterWidget(VideoControlOverlay widget) {
widget.controller.removeListener(_onValueChange);
}
@override
Widget build(BuildContext context) {
final progressBarBorderRadius = 123.0;
final mediaQuery = MediaQuery.of(context);
final viewInsets = widget.viewInsets ?? mediaQuery.viewInsets;
final viewPadding = widget.viewPadding ?? mediaQuery.viewPadding;
final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + EdgeInsets.symmetric(horizontal: 8.0);
return Padding(
padding: safePadding,
child: value.hasError
? OverlayButton(
scale: scale,
child: IconButton(
icon: Icon(Icons.open_in_new),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeType),
tooltip: 'Open',
),
)
: SizedBox(
width: mediaQuery.size.width - safePadding.horizontal,
child: Row(
children: [
OverlayButton(
scale: scale,
child: value.isPlaying
? IconButton(
icon: Icon(Icons.pause),
onPressed: () => _playPause(),
tooltip: 'Pause',
)
: IconButton(
icon: Icon(Icons.play_arrow),
onPressed: () => _playPause(),
tooltip: 'Play',
),
),
SizedBox(width: 8),
Expanded(
child: SizeTransition(
sizeFactor: scale,
child: BlurredRRect(
borderRadius: progressBarBorderRadius,
child: Container(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 16) + EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.black26,
border: Border.all(color: Colors.white30, width: 0.5),
borderRadius: BorderRadius.all(
Radius.circular(progressBarBorderRadius),
),
),
child: Column(
children: [
Row(
children: [
Text(formatDuration(value.position ?? Duration.zero)),
Spacer(),
Text(formatDuration(value.duration ?? Duration.zero)),
],
),
LinearProgressIndicator(value: progress),
],
),
),
),
),
),
SizedBox(width: 8),
OverlayButton(
scale: scale,
child: IconButton(
icon: Icon(Icons.fullscreen),
onPressed: () => _goFullscreen(),
tooltip: 'Fullscreen',
),
),
],
),
),
);
}
_playPause() async {
if (value.isPlaying) {
controller.pause();
} else {
if (!value.initialized) {
await controller.initialize();
}
controller.play();
}
setState(() {});
}
_goFullscreen() {}
_onValueChange() => setState(() {});
}

View file

@ -1,49 +1,82 @@
import 'dart:io';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
class AvesVideo extends StatefulWidget { class AvesVideo extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final VideoPlayerController controller;
const AvesVideo({Key key, this.entry}) : super(key: key); const AvesVideo({
Key key,
@required this.entry,
@required this.controller,
}) : super(key: key);
@override @override
State<StatefulWidget> createState() => AvesVideoState(); State<StatefulWidget> createState() => AvesVideoState();
} }
class AvesVideoState extends State<AvesVideo> { class AvesVideoState extends State<AvesVideo> {
VideoPlayerController videoPlayerController;
ChewieController chewieController;
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
VideoPlayerValue get value => widget.controller.value;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
videoPlayerController = VideoPlayerController.file( registerWidget(widget);
File(entry.path), _onValueChange();
); }
chewieController = ChewieController(
videoPlayerController: videoPlayerController, @override
aspectRatio: entry.aspectRatio, void didUpdateWidget(AvesVideo oldWidget) {
autoInitialize: true, super.didUpdateWidget(oldWidget);
); unregisterWidget(oldWidget);
registerWidget(widget);
} }
@override @override
void dispose() { void dispose() {
videoPlayerController.dispose(); unregisterWidget(widget);
chewieController.dispose();
super.dispose(); super.dispose();
} }
registerWidget(AvesVideo widget) {
widget.controller.addListener(_onValueChange);
}
unregisterWidget(AvesVideo widget) {
widget.controller.removeListener(_onValueChange);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Chewie( if (value == null) return SizedBox();
controller: chewieController, if (value.hasError)
return Center(
child: Icon(
Icons.error_outline,
size: 32,
),
);
return Center(
child: Container(
width: MediaQuery.of(context).size.width,
child: AspectRatio(
aspectRatio: entry.aspectRatio,
child: VideoPlayer(widget.controller),
),
),
); );
} }
_onValueChange() {
if (!value.isPlaying && value.position == value.duration) goToBeginning();
setState(() {});
}
goToBeginning() async {
await widget.controller.seekTo(Duration.zero);
await widget.controller.pause();
}
} }