diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index dc4bc823b..c7cd93d3d 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -67,6 +67,8 @@ class ImageEntry { }; } + bool get isGif => mimeType == MimeTypes.MIME_GIF; + bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO); int getMegaPixels() { @@ -83,4 +85,19 @@ class ImageEntry { final d = getBestDate(); return d == null ? null : DateTime(d.year, d.month); } + + String getDurationText() { + 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'; + } } diff --git a/lib/thumbnail.dart b/lib/thumbnail.dart index 8da77bdeb..c5de7bd9b 100644 --- a/lib/thumbnail.dart +++ b/lib/thumbnail.dart @@ -3,7 +3,6 @@ import 'dart:typed_data'; import 'package:aves/model/image_decode_service.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/mime_types.dart'; import 'package:flutter/material.dart'; import 'package:transparent_image/transparent_image.dart'; @@ -26,7 +25,7 @@ class Thumbnail extends StatefulWidget { class ThumbnailState extends State { Future _byteLoader; - String get mimeType => widget.entry.mimeType; + ImageEntry get entry => widget.entry; String get uri => widget.entry.uri; @@ -56,55 +55,95 @@ class ThumbnailState extends State { @override Widget build(BuildContext context) { - final isVideo = mimeType.startsWith(MimeTypes.MIME_VIDEO); - final isGif = mimeType == MimeTypes.MIME_GIF; - final iconSize = widget.extent / 4; - return Container( - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.shade700, - width: 0.5, - ), + final fontSize = (widget.extent / 8).roundToDouble(); + final iconSize = fontSize * 2; + return DefaultTextStyle( + style: TextStyle( + color: Colors.grey[200], + fontSize: fontSize, ), - child: FutureBuilder( - future: _byteLoader, - builder: (futureContext, AsyncSnapshot snapshot) { - final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage; - return Stack( - alignment: AlignmentDirectional.bottomStart, - children: [ - Hero( - tag: uri, - child: LayoutBuilder(builder: (context, constraints) { - // during hero animation back from a fullscreen image, - // the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints) - // so we wrap the image to apply better constraints - final dim = min(constraints.maxWidth, constraints.maxHeight); - return Container( - alignment: Alignment.center, - constraints: BoxConstraints.tight(Size(dim, dim)), - child: Image.memory( - bytes, - width: dim, - height: dim, - fit: BoxFit.cover, - ), - ); - }), - ), - if (isVideo) - Icon( - Icons.play_circle_outline, - size: iconSize, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.shade700, + width: 0.5, + ), + ), + child: FutureBuilder( + future: _byteLoader, + builder: (futureContext, AsyncSnapshot snapshot) { + final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage; + return Stack( + alignment: AlignmentDirectional.bottomStart, + children: [ + Hero( + tag: uri, + child: LayoutBuilder(builder: (context, constraints) { + // during hero animation back from a fullscreen image, + // the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints) + // so we wrap the image to apply better constraints + final dim = min(constraints.maxWidth, constraints.maxHeight); + return Container( + alignment: Alignment.center, + constraints: BoxConstraints.tight(Size(dim, dim)), + child: Image.memory( + bytes, + width: dim, + height: dim, + fit: BoxFit.cover, + ), + ); + }), ), - if (isGif) - Icon( - Icons.gif, - size: iconSize, - ), - ], - ); - }), + if (entry.isVideo) + VideoTag( + entry: entry, + iconSize: iconSize, + ) + else if (entry.isGif) + Icon( + Icons.gif, + size: iconSize, + ), + ], + ); + }), + ), + ); + } +} + +class VideoTag extends StatelessWidget { + final ImageEntry entry; + final double iconSize; + + const VideoTag({Key key, this.entry, this.iconSize}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.all(1), + padding: EdgeInsets.only(right: 4), + decoration: BoxDecoration( + color: Color(0xBB000000), + borderRadius: BorderRadius.all( + Radius.circular(iconSize), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.play_circle_outline, + size: iconSize, + ), + SizedBox(width: 2), + Text( + entry.getDurationText(), + ) + ], + ), ); } } diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index 69f3bccea..836178b14 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -4,11 +4,14 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/fullscreen/info_page.dart'; import 'package:aves/widgets/fullscreen/overlay.dart'; +import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; +import 'package:screen/screen.dart'; +import 'package:video_player/video_player.dart'; class FullscreenPage extends StatefulWidget { final List entries; @@ -50,6 +53,8 @@ class FullscreenPageState extends State with SingleTickerProvide _topOverlayScale = CurvedAnimation(parent: _overlayAnimationController, curve: Curves.easeOutQuart, reverseCurve: Curves.easeInQuart); _bottomOverlayOffset = Tween(begin: Offset(0, 1), end: Offset(0, 0)).animate(CurvedAnimation(parent: _overlayAnimationController, curve: Curves.easeOutQuart, reverseCurve: Curves.easeInQuart)); _overlayVisible.addListener(onOverlayVisibleChange); + + Screen.keepOn(true); initOverlay(); } @@ -68,64 +73,70 @@ class FullscreenPageState extends State with SingleTickerProvide @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - PageView( - scrollDirection: Axis.vertical, - controller: _verticalPager, - physics: _isInitialScale ? PageScrollPhysics() : NeverScrollableScrollPhysics(), - onPageChanged: (page) => setState(() => _currentVerticalPage = page), - children: [ - ImagePage( - entries: entries, - pageController: _horizontalPager, - onTap: () => _overlayVisible.value = !_overlayVisible.value, - onPageChanged: (page) => setState(() => _currentHorizontalPage = page), - onScaleChanged: (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial), - ), - NotificationListener( - onNotification: (notification) { - if (notification is BackUpNotification) { - _verticalPager.animateToPage( - 0, - duration: const Duration(milliseconds: 400), - curve: Curves.easeInOut, - ); - } - return false; - }, - child: InfoPage( - entry: entries[_currentHorizontalPage], - ), - ), - ], - ), - if (_currentHorizontalPage != null && _currentVerticalPage == 0) ...[ - FullscreenTopOverlay( - entries: entries, - index: _currentHorizontalPage, - scale: _topOverlayScale, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - ), - Positioned( - bottom: 0, - child: SlideTransition( - position: _bottomOverlayOffset, - child: FullscreenBottomOverlay( + return WillPopScope( + onWillPop: () { + Screen.keepOn(false); + SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); + return Future.value(true); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + PageView( + scrollDirection: Axis.vertical, + controller: _verticalPager, + physics: _isInitialScale ? PageScrollPhysics() : NeverScrollableScrollPhysics(), + onPageChanged: (page) => setState(() => _currentVerticalPage = page), + children: [ + ImagePage( entries: entries, - index: _currentHorizontalPage, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, + pageController: _horizontalPager, + onTap: () => _overlayVisible.value = !_overlayVisible.value, + onPageChanged: (page) => setState(() => _currentHorizontalPage = page), + onScaleChanged: (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial), ), + NotificationListener( + onNotification: (notification) { + if (notification is BackUpNotification) { + _verticalPager.animateToPage( + 0, + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + ); + } + return false; + }, + child: InfoPage( + entry: entries[_currentHorizontalPage], + ), + ), + ], + ), + if (_currentHorizontalPage != null && _currentVerticalPage == 0) ...[ + FullscreenTopOverlay( + entries: entries, + index: _currentHorizontalPage, + scale: _topOverlayScale, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, ), - ) - ] - ], - ), - resizeToAvoidBottomInset: false, + Positioned( + bottom: 0, + child: SlideTransition( + position: _bottomOverlayOffset, + child: FullscreenBottomOverlay( + entries: entries, + index: _currentHorizontalPage, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + ), + ), + ) + ] + ], + ), + resizeToAvoidBottomInset: false, // Hero( // tag: uri, // child: Stack( @@ -154,6 +165,7 @@ class FullscreenPageState extends State with SingleTickerProvide // ], // ), // ), + ), ); } @@ -162,9 +174,9 @@ class FullscreenPageState extends State with SingleTickerProvide SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); _overlayAnimationController.forward(); } else { - final mq = MediaQuery.of(context); - _frozenViewInsets = mq.viewInsets; - _frozenViewPadding = mq.viewPadding; + final mediaQuery = MediaQuery.of(context); + _frozenViewInsets = mediaQuery.viewInsets; + _frozenViewPadding = mediaQuery.viewPadding; SystemChrome.setEnabledSystemUIOverlays([]); await _overlayAnimationController.reverse(); _frozenViewInsets = null; @@ -198,8 +210,24 @@ class ImagePageState extends State with AutomaticKeepAliveClientMixin super.build(context); return PhotoViewGallery.builder( itemCount: widget.entries.length, - builder: (context, index) { + builder: (galleryContext, index) { final entry = widget.entries[index]; + if (entry.isVideo) { + final screenSize = MediaQuery.of(galleryContext).size; + final videoAspectRatio = entry.width / entry.height; + final childWidth = min(screenSize.width, entry.width); + final childHeight = childWidth / videoAspectRatio; + debugPrint('ImagePageState video path=${entry.path} childWidth=$childWidth childHeight=$childHeight var=$videoAspectRatio car=${childWidth / childHeight}'); + + return PhotoViewGalleryPageOptions.customChild( + child: AvesVideo(entry: entry), + childSize: Size(childWidth, childHeight), + // no heroTag because `Chewie` already internally builds one with the videoController + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + onTapUp: (tapContext, details, value) => widget.onTap?.call(), + ); + } return PhotoViewGalleryPageOptions( imageProvider: FileImage(File(entry.path)), heroTag: entry.uri, @@ -222,3 +250,43 @@ class ImagePageState extends State with AutomaticKeepAliveClientMixin @override bool get wantKeepAlive => true; } + +class AvesVideo extends StatefulWidget { + final ImageEntry entry; + + const AvesVideo({Key key, this.entry}) : super(key: key); + + @override + State createState() => AvesVideoState(); +} + +class AvesVideoState extends State { + VideoPlayerController videoPlayerController; + ChewieController chewieController; + + @override + void initState() { + super.initState(); + videoPlayerController = VideoPlayerController.file( + File(widget.entry.path), + // ensure the first frame is shown after the video is initialized + )..initialize().then((_) => setState(() {})); + chewieController = ChewieController( + videoPlayerController: videoPlayerController, + ); + } + + @override + void dispose() { + videoPlayerController.dispose(); + chewieController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Chewie( + controller: chewieController, + ); + } +} diff --git a/lib/widgets/fullscreen/info_page.dart b/lib/widgets/fullscreen/info_page.dart index f078ea160..deb3dd83b 100644 --- a/lib/widgets/fullscreen/info_page.dart +++ b/lib/widgets/fullscreen/info_page.dart @@ -39,7 +39,7 @@ class InfoPageState extends State { Widget build(BuildContext context) { final date = entry.getBestDate(); final dateText = '${DateFormat.yMMMd().format(date)} – ${DateFormat.Hm().format(date)}'; - final resolutionText = '${entry.width} × ${entry.height}${entry.isVideo ? '': ' (${entry.getMegaPixels()} MP)'}'; + final resolutionText = '${entry.width} × ${entry.height}${entry.isVideo ? '' : ' (${entry.getMegaPixels()} MP)'}'; return Scaffold( appBar: AppBar( leading: IconButton( @@ -75,6 +75,7 @@ class InfoPageState extends State { SectionRow('File'), InfoRow('Title', entry.title), InfoRow('Date', dateText), + if (entry.isVideo) InfoRow('Duration', entry.getDurationText()), InfoRow('Resolution', resolutionText), InfoRow('Size', formatFilesize(entry.sizeBytes)), InfoRow('Path', entry.path), @@ -86,7 +87,7 @@ class InfoPageState extends State { return Text(snapshot.error); } if (snapshot.connectionState != ConnectionState.done) { - return CircularProgressIndicator(); + return SizedBox.shrink(); } final metadataMap = snapshot.data.cast(); final directoryNames = metadataMap.keys.toList()..sort(); @@ -122,17 +123,15 @@ class SectionRow extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: Row( - children: [ - Expanded(child: Divider(color: Colors.white70)), - SizedBox(width: 8), - Text(title, style: TextStyle(fontSize: 18)), - SizedBox(width: 8), - Expanded(child: Divider(color: Colors.white70)), - ], - ), + return Row( + children: [ + Expanded(child: Divider(color: Colors.white70)), + Padding( + padding: EdgeInsets.all(16.0), + child: Text(title, style: TextStyle(fontSize: 20)), + ), + Expanded(child: Divider(color: Colors.white70)), + ], ); } } diff --git a/lib/widgets/fullscreen/overlay.dart b/lib/widgets/fullscreen/overlay.dart index c899ebe09..843b5bf88 100644 --- a/lib/widgets/fullscreen/overlay.dart +++ b/lib/widgets/fullscreen/overlay.dart @@ -108,31 +108,29 @@ class _FullscreenBottomOverlayState extends State { final viewInsets = widget.viewInsets ?? mediaQuery.viewInsets; final viewPadding = widget.viewPadding ?? mediaQuery.viewPadding; final overlayContentMaxWidth = mediaQuery.size.width - viewPadding.horizontal - innerPadding.horizontal; - return BlurredRect( - child: Container( - color: kOverlayBackground, - child: IgnorePointer( + return IgnorePointer( + child: BlurredRect( + child: Container( + color: kOverlayBackground, + padding: viewInsets + viewPadding.copyWith(top: 0), child: Padding( - padding: viewInsets + viewPadding.copyWith(top: 0), - child: Container( - padding: innerPadding, - child: FutureBuilder( - future: _detailLoader, - builder: (futureContext, AsyncSnapshot snapshot) { - if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { - _lastDetails = snapshot.data; - _lastEntry = entry; - } - return _lastEntry == null - ? SizedBox.shrink() - : _FullscreenBottomOverlayContent( - entry: _lastEntry, - details: _lastDetails, - position: '${widget.index + 1}/${widget.entries.length}', - maxWidth: overlayContentMaxWidth, - ); - }, - ), + padding: innerPadding, + child: FutureBuilder( + future: _detailLoader, + builder: (futureContext, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { + _lastDetails = snapshot.data; + _lastEntry = entry; + } + return _lastEntry == null + ? SizedBox.shrink() + : _FullscreenBottomOverlayContent( + entry: _lastEntry, + details: _lastDetails, + position: '${widget.index + 1}/${widget.entries.length}', + maxWidth: overlayContentMaxWidth, + ); + }, ), ), ), diff --git a/pubspec.lock b/pubspec.lock index 5d1019cc3..db0902693 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,6 +29,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.2" + chewie: + dependency: "direct main" + description: + name: chewie + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7" collection: dependency: "direct main" description: @@ -74,6 +81,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.6" + open_iconic_flutter: + dependency: transitive + description: + name: open_iconic_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" path: dependency: transitive description: @@ -102,6 +116,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.3" + screen: + dependency: "direct main" + description: + name: screen + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.5" sky_engine: dependency: transitive description: flutter @@ -170,6 +191,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.8" + video_player: + dependency: transitive + description: + name: video_player + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.1+6" sdks: dart: ">=2.2.2 <3.0.0" flutter: ">=1.5.9-pre.94 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index d9a6c7416..17be43d36 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,10 +19,12 @@ environment: dependencies: flutter: sdk: flutter + chewie: collection: flutter_sticky_header: intl: photo_view: + screen: transparent_image: dev_dependencies: