From c4d44b49eac1548b6a53470489da1a6677852736 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 6 Oct 2019 16:30:06 +0900 Subject: [PATCH] custom video control overlay --- lib/model/image_entry.dart | 16 +- lib/utils/date_utils.dart | 13 ++ lib/widgets/common/blurred.dart | 18 +++ lib/widgets/fullscreen/image_page.dart | 112 ++++++++++---- lib/widgets/fullscreen/overlay_bottom.dart | 4 +- lib/widgets/fullscreen/overlay_top.dart | 6 +- lib/widgets/fullscreen/overlay_video.dart | 165 +++++++++++++++++++++ lib/widgets/fullscreen/video.dart | 71 ++++++--- 8 files changed, 341 insertions(+), 64 deletions(-) create mode 100644 lib/widgets/fullscreen/overlay_video.dart diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 7417c85ed..eab661a9c 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -4,6 +4,7 @@ import 'package:aves/model/image_file_service.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; +import 'package:aves/utils/date_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:geocoder/geocoder.dart'; import 'package:path/path.dart'; @@ -124,20 +125,7 @@ class ImageEntry { return d == null ? null : DateTime(d.year, d.month); } - String get durationText { - 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'; - } + String get durationText => formatDuration(Duration(milliseconds: durationMillis)); bool get hasGps => isCatalogued && catalogMetadata.latitude != null; diff --git a/lib/utils/date_utils.dart b/lib/utils/date_utils.dart index 71ec3849d..e8481e5b6 100644 --- a/lib/utils/date_utils.dart +++ b/lib/utils/date_utils.dart @@ -9,3 +9,16 @@ bool isToday(DateTime d) => isAtSameDayAs(d, DateTime.now()); bool isThisMonth(DateTime d) => isAtSameMonthAs(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'; +} diff --git a/lib/widgets/common/blurred.dart b/lib/widgets/common/blurred.dart index 1060c005c..04e7e75aa 100644 --- a/lib/widgets/common/blurred.dart +++ b/lib/widgets/common/blurred.dart @@ -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 { final Widget child; diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index efd5f4638..eff6a48b5 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -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/overlay_bottom.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:flushbar/flushbar.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:printing/printing.dart'; import 'package:screen/screen.dart'; +import 'package:tuple/tuple.dart'; +import 'package:video_player/video_player.dart'; class FullscreenPage extends AnimatedWidget { final ImageCollection collection; @@ -92,6 +95,7 @@ class FullscreenBodyState extends State with SingleTickerProvide Animation _topOverlayScale; Animation _bottomOverlayOffset; EdgeInsets _frozenViewInsets, _frozenViewPadding; + List> _videoControllers = List(); ImageCollection get collection => widget.collection; @@ -117,6 +121,7 @@ class FullscreenBodyState extends State with SingleTickerProvide curve: Curves.easeOutQuart, )); _overlayVisible.addListener(onOverlayVisibleChange); + initVideoController(); Screen.keepOn(true); initOverlay(); @@ -132,12 +137,13 @@ class FullscreenBodyState extends State with SingleTickerProvide @override void dispose() { _overlayVisible.removeListener(onOverlayVisibleChange); + _videoControllers.forEach((kv) => kv.item2.dispose()); super.dispose(); } @override Widget build(BuildContext context) { - final entry = _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; + final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; return WillPopScope( onWillPop: () { if (_currentVerticalPage == 1) { @@ -160,8 +166,9 @@ class FullscreenBodyState extends State with SingleTickerProvide collection: collection, pageController: _horizontalPager, onTap: () => _overlayVisible.value = !_overlayVisible.value, - onPageChanged: (page) => setState(() => _currentHorizontalPage = page), + onPageChanged: onHorizontalPageChanged, onScaleChanged: (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial), + videoControllers: _videoControllers, ), NotificationListener( onNotification: (notification) { @@ -172,33 +179,51 @@ class FullscreenBodyState extends State with SingleTickerProvide ), ], ), - if (_currentHorizontalPage != null && _currentVerticalPage == 0) ...[ - 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, - ), - ), - ) - ] + ..._buildOverlay(entry) ], ), ); } + List _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) { return _verticalPager.animateToPage( page, @@ -344,6 +369,30 @@ class FullscreenBodyState extends State with SingleTickerProvide if (newName == null || newName.isEmpty) return; 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 } @@ -354,6 +403,7 @@ class ImagePage extends StatefulWidget { final VoidCallback onTap; final ValueChanged onPageChanged; final ValueChanged onScaleChanged; + final List> videoControllers; const ImagePage({ this.collection, @@ -361,6 +411,7 @@ class ImagePage extends StatefulWidget { this.onTap, this.onPageChanged, this.onScaleChanged, + this.videoControllers, }); @override @@ -378,10 +429,19 @@ class ImagePageState extends State with AutomaticKeepAliveClientMixin builder: (galleryContext, index) { final entry = entries[index]; if (entry.isVideo) { + final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2; return PhotoViewGalleryPageOptions.customChild( - child: AvesVideo(entry: entry), + child: videoController != null + ? AvesVideo( + entry: entry, + controller: videoController, + ) + : SizedBox(), 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, initialScale: PhotoViewComputedScale.contained, onTapUp: (tapContext, details, value) => widget.onTap?.call(), diff --git a/lib/widgets/fullscreen/overlay_bottom.dart b/lib/widgets/fullscreen/overlay_bottom.dart index f2fe316ff..768d534e2 100644 --- a/lib/widgets/fullscreen/overlay_bottom.dart +++ b/lib/widgets/fullscreen/overlay_bottom.dart @@ -16,8 +16,8 @@ class FullscreenBottomOverlay extends StatefulWidget { const FullscreenBottomOverlay({ Key key, - this.entries, - this.index, + @required this.entries, + @required this.index, this.viewInsets, this.viewPadding, }) : super(key: key); diff --git a/lib/widgets/fullscreen/overlay_top.dart b/lib/widgets/fullscreen/overlay_top.dart index c5855eff0..efa56c953 100644 --- a/lib/widgets/fullscreen/overlay_top.dart +++ b/lib/widgets/fullscreen/overlay_top.dart @@ -15,9 +15,9 @@ class FullscreenTopOverlay extends StatelessWidget { const FullscreenTopOverlay({ Key key, - this.entries, - this.index, - this.scale, + @required this.entries, + @required this.index, + @required this.scale, this.viewInsets, this.viewPadding, this.onActionSelected, diff --git a/lib/widgets/fullscreen/overlay_video.dart b/lib/widgets/fullscreen/overlay_video.dart new file mode 100644 index 000000000..e3a2d2f5a --- /dev/null +++ b/lib/widgets/fullscreen/overlay_video.dart @@ -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 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 createState() => VideoControlOverlayState(); +} + +class VideoControlOverlayState extends State { + ImageEntry get entry => widget.entry; + + Animation 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(() {}); +} diff --git a/lib/widgets/fullscreen/video.dart b/lib/widgets/fullscreen/video.dart index a882bd254..19fefdeb9 100644 --- a/lib/widgets/fullscreen/video.dart +++ b/lib/widgets/fullscreen/video.dart @@ -1,49 +1,82 @@ -import 'dart:io'; - import 'package:aves/model/image_entry.dart'; -import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; class AvesVideo extends StatefulWidget { 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 State createState() => AvesVideoState(); } class AvesVideoState extends State { - VideoPlayerController videoPlayerController; - ChewieController chewieController; - ImageEntry get entry => widget.entry; + VideoPlayerValue get value => widget.controller.value; + @override void initState() { super.initState(); - videoPlayerController = VideoPlayerController.file( - File(entry.path), - ); - chewieController = ChewieController( - videoPlayerController: videoPlayerController, - aspectRatio: entry.aspectRatio, - autoInitialize: true, - ); + registerWidget(widget); + _onValueChange(); + } + + @override + void didUpdateWidget(AvesVideo oldWidget) { + super.didUpdateWidget(oldWidget); + unregisterWidget(oldWidget); + registerWidget(widget); } @override void dispose() { - videoPlayerController.dispose(); - chewieController.dispose(); + unregisterWidget(widget); super.dispose(); } + registerWidget(AvesVideo widget) { + widget.controller.addListener(_onValueChange); + } + + unregisterWidget(AvesVideo widget) { + widget.controller.removeListener(_onValueChange); + } + @override Widget build(BuildContext context) { - return Chewie( - controller: chewieController, + if (value == null) return SizedBox(); + 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(); + } }