video: duration in thumbnail / info, player in fullscreen

This commit is contained in:
Thibault Deckers 2019-08-04 13:40:33 +09:00
parent 49a28c6d09
commit cc0283d393
7 changed files with 296 additions and 145 deletions

View file

@ -67,6 +67,8 @@ class ImageEntry {
}; };
} }
bool get isGif => mimeType == MimeTypes.MIME_GIF;
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO); bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
int getMegaPixels() { int getMegaPixels() {
@ -83,4 +85,19 @@ class ImageEntry {
final d = getBestDate(); final d = getBestDate();
return d == null ? null : DateTime(d.year, d.month); 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';
}
} }

View file

@ -3,7 +3,6 @@ import 'dart:typed_data';
import 'package:aves/model/image_decode_service.dart'; import 'package:aves/model/image_decode_service.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:transparent_image/transparent_image.dart'; import 'package:transparent_image/transparent_image.dart';
@ -26,7 +25,7 @@ class Thumbnail extends StatefulWidget {
class ThumbnailState extends State<Thumbnail> { class ThumbnailState extends State<Thumbnail> {
Future<Uint8List> _byteLoader; Future<Uint8List> _byteLoader;
String get mimeType => widget.entry.mimeType; ImageEntry get entry => widget.entry;
String get uri => widget.entry.uri; String get uri => widget.entry.uri;
@ -56,55 +55,95 @@ class ThumbnailState extends State<Thumbnail> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isVideo = mimeType.startsWith(MimeTypes.MIME_VIDEO); final fontSize = (widget.extent / 8).roundToDouble();
final isGif = mimeType == MimeTypes.MIME_GIF; final iconSize = fontSize * 2;
final iconSize = widget.extent / 4; return DefaultTextStyle(
return Container( style: TextStyle(
decoration: BoxDecoration( color: Colors.grey[200],
border: Border.all( fontSize: fontSize,
color: Colors.grey.shade700,
width: 0.5,
),
), ),
child: FutureBuilder( child: Container(
future: _byteLoader, decoration: BoxDecoration(
builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) { border: Border.all(
final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage; color: Colors.grey.shade700,
return Stack( width: 0.5,
alignment: AlignmentDirectional.bottomStart, ),
children: [ ),
Hero( child: FutureBuilder(
tag: uri, future: _byteLoader,
child: LayoutBuilder(builder: (context, constraints) { builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) {
// during hero animation back from a fullscreen image, final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage;
// the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints) return Stack(
// so we wrap the image to apply better constraints alignment: AlignmentDirectional.bottomStart,
final dim = min(constraints.maxWidth, constraints.maxHeight); children: [
return Container( Hero(
alignment: Alignment.center, tag: uri,
constraints: BoxConstraints.tight(Size(dim, dim)), child: LayoutBuilder(builder: (context, constraints) {
child: Image.memory( // during hero animation back from a fullscreen image,
bytes, // the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints)
width: dim, // so we wrap the image to apply better constraints
height: dim, final dim = min(constraints.maxWidth, constraints.maxHeight);
fit: BoxFit.cover, return Container(
), alignment: Alignment.center,
); constraints: BoxConstraints.tight(Size(dim, dim)),
}), child: Image.memory(
), bytes,
if (isVideo) width: dim,
Icon( height: dim,
Icons.play_circle_outline, fit: BoxFit.cover,
size: iconSize, ),
);
}),
), ),
if (isGif) if (entry.isVideo)
Icon( VideoTag(
Icons.gif, entry: entry,
size: iconSize, 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(),
)
],
),
); );
} }
} }

View file

@ -4,11 +4,14 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/info_page.dart'; import 'package:aves/widgets/fullscreen/info_page.dart';
import 'package:aves/widgets/fullscreen/overlay.dart'; import 'package:aves/widgets/fullscreen/overlay.dart';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart'; 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:screen/screen.dart';
import 'package:video_player/video_player.dart';
class FullscreenPage extends StatefulWidget { class FullscreenPage extends StatefulWidget {
final List<ImageEntry> entries; final List<ImageEntry> entries;
@ -50,6 +53,8 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
_topOverlayScale = CurvedAnimation(parent: _overlayAnimationController, curve: Curves.easeOutQuart, reverseCurve: Curves.easeInQuart); _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)); _bottomOverlayOffset = Tween(begin: Offset(0, 1), end: Offset(0, 0)).animate(CurvedAnimation(parent: _overlayAnimationController, curve: Curves.easeOutQuart, reverseCurve: Curves.easeInQuart));
_overlayVisible.addListener(onOverlayVisibleChange); _overlayVisible.addListener(onOverlayVisibleChange);
Screen.keepOn(true);
initOverlay(); initOverlay();
} }
@ -68,64 +73,70 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return WillPopScope(
backgroundColor: Colors.black, onWillPop: () {
body: Stack( Screen.keepOn(false);
children: [ SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
PageView( return Future.value(true);
scrollDirection: Axis.vertical, },
controller: _verticalPager, child: Scaffold(
physics: _isInitialScale ? PageScrollPhysics() : NeverScrollableScrollPhysics(), backgroundColor: Colors.black,
onPageChanged: (page) => setState(() => _currentVerticalPage = page), body: Stack(
children: [ children: [
ImagePage( PageView(
entries: entries, scrollDirection: Axis.vertical,
pageController: _horizontalPager, controller: _verticalPager,
onTap: () => _overlayVisible.value = !_overlayVisible.value, physics: _isInitialScale ? PageScrollPhysics() : NeverScrollableScrollPhysics(),
onPageChanged: (page) => setState(() => _currentHorizontalPage = page), onPageChanged: (page) => setState(() => _currentVerticalPage = page),
onScaleChanged: (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial), children: [
), ImagePage(
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(
entries: entries, entries: entries,
index: _currentHorizontalPage, pageController: _horizontalPager,
viewInsets: _frozenViewInsets, onTap: () => _overlayVisible.value = !_overlayVisible.value,
viewPadding: _frozenViewPadding, 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,
resizeToAvoidBottomInset: false, child: FullscreenBottomOverlay(
entries: entries,
index: _currentHorizontalPage,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
),
)
]
],
),
resizeToAvoidBottomInset: false,
// Hero( // Hero(
// tag: uri, // tag: uri,
// child: Stack( // child: Stack(
@ -154,6 +165,7 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
// ], // ],
// ), // ),
// ), // ),
),
); );
} }
@ -162,9 +174,9 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
_overlayAnimationController.forward(); _overlayAnimationController.forward();
} else { } else {
final mq = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
_frozenViewInsets = mq.viewInsets; _frozenViewInsets = mediaQuery.viewInsets;
_frozenViewPadding = mq.viewPadding; _frozenViewPadding = mediaQuery.viewPadding;
SystemChrome.setEnabledSystemUIOverlays([]); SystemChrome.setEnabledSystemUIOverlays([]);
await _overlayAnimationController.reverse(); await _overlayAnimationController.reverse();
_frozenViewInsets = null; _frozenViewInsets = null;
@ -198,8 +210,24 @@ class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin
super.build(context); super.build(context);
return PhotoViewGallery.builder( return PhotoViewGallery.builder(
itemCount: widget.entries.length, itemCount: widget.entries.length,
builder: (context, index) { builder: (galleryContext, index) {
final entry = widget.entries[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( return PhotoViewGalleryPageOptions(
imageProvider: FileImage(File(entry.path)), imageProvider: FileImage(File(entry.path)),
heroTag: entry.uri, heroTag: entry.uri,
@ -222,3 +250,43 @@ class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }
class AvesVideo extends StatefulWidget {
final ImageEntry entry;
const AvesVideo({Key key, this.entry}) : super(key: key);
@override
State<StatefulWidget> createState() => AvesVideoState();
}
class AvesVideoState extends State<AvesVideo> {
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,
);
}
}

View file

@ -39,7 +39,7 @@ class InfoPageState extends State<InfoPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = entry.getBestDate(); final date = entry.getBestDate();
final dateText = '${DateFormat.yMMMd().format(date)} ${DateFormat.Hm().format(date)}'; 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: IconButton( leading: IconButton(
@ -75,6 +75,7 @@ class InfoPageState extends State<InfoPage> {
SectionRow('File'), SectionRow('File'),
InfoRow('Title', entry.title), InfoRow('Title', entry.title),
InfoRow('Date', dateText), InfoRow('Date', dateText),
if (entry.isVideo) InfoRow('Duration', entry.getDurationText()),
InfoRow('Resolution', resolutionText), InfoRow('Resolution', resolutionText),
InfoRow('Size', formatFilesize(entry.sizeBytes)), InfoRow('Size', formatFilesize(entry.sizeBytes)),
InfoRow('Path', entry.path), InfoRow('Path', entry.path),
@ -86,7 +87,7 @@ class InfoPageState extends State<InfoPage> {
return Text(snapshot.error); return Text(snapshot.error);
} }
if (snapshot.connectionState != ConnectionState.done) { if (snapshot.connectionState != ConnectionState.done) {
return CircularProgressIndicator(); return SizedBox.shrink();
} }
final metadataMap = snapshot.data.cast<String, Map>(); final metadataMap = snapshot.data.cast<String, Map>();
final directoryNames = metadataMap.keys.toList()..sort(); final directoryNames = metadataMap.keys.toList()..sort();
@ -122,17 +123,15 @@ class SectionRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Row(
padding: EdgeInsets.symmetric(vertical: 8.0), children: [
child: Row( Expanded(child: Divider(color: Colors.white70)),
children: [ Padding(
Expanded(child: Divider(color: Colors.white70)), padding: EdgeInsets.all(16.0),
SizedBox(width: 8), child: Text(title, style: TextStyle(fontSize: 20)),
Text(title, style: TextStyle(fontSize: 18)), ),
SizedBox(width: 8), Expanded(child: Divider(color: Colors.white70)),
Expanded(child: Divider(color: Colors.white70)), ],
],
),
); );
} }
} }

View file

@ -108,31 +108,29 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
final viewInsets = widget.viewInsets ?? mediaQuery.viewInsets; final viewInsets = widget.viewInsets ?? mediaQuery.viewInsets;
final viewPadding = widget.viewPadding ?? mediaQuery.viewPadding; final viewPadding = widget.viewPadding ?? mediaQuery.viewPadding;
final overlayContentMaxWidth = mediaQuery.size.width - viewPadding.horizontal - innerPadding.horizontal; final overlayContentMaxWidth = mediaQuery.size.width - viewPadding.horizontal - innerPadding.horizontal;
return BlurredRect( return IgnorePointer(
child: Container( child: BlurredRect(
color: kOverlayBackground, child: Container(
child: IgnorePointer( color: kOverlayBackground,
padding: viewInsets + viewPadding.copyWith(top: 0),
child: Padding( child: Padding(
padding: viewInsets + viewPadding.copyWith(top: 0), padding: innerPadding,
child: Container( child: FutureBuilder(
padding: innerPadding, future: _detailLoader,
child: FutureBuilder( builder: (futureContext, AsyncSnapshot<Map> snapshot) {
future: _detailLoader, if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) {
builder: (futureContext, AsyncSnapshot<Map> snapshot) { _lastDetails = snapshot.data;
if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { _lastEntry = entry;
_lastDetails = snapshot.data; }
_lastEntry = entry; return _lastEntry == null
} ? SizedBox.shrink()
return _lastEntry == null : _FullscreenBottomOverlayContent(
? SizedBox.shrink() entry: _lastEntry,
: _FullscreenBottomOverlayContent( details: _lastDetails,
entry: _lastEntry, position: '${widget.index + 1}/${widget.entries.length}',
details: _lastDetails, maxWidth: overlayContentMaxWidth,
position: '${widget.index + 1}/${widget.entries.length}', );
maxWidth: overlayContentMaxWidth, },
);
},
),
), ),
), ),
), ),

View file

@ -29,6 +29,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
chewie:
dependency: "direct main"
description:
name: chewie
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.7"
collection: collection:
dependency: "direct main" dependency: "direct main"
description: description:
@ -74,6 +81,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.6" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -102,6 +116,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.3" version: "2.0.3"
screen:
dependency: "direct main"
description:
name: screen
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.5"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -170,6 +191,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.8" 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: sdks:
dart: ">=2.2.2 <3.0.0" dart: ">=2.2.2 <3.0.0"
flutter: ">=1.5.9-pre.94 <2.0.0" flutter: ">=1.5.9-pre.94 <2.0.0"

View file

@ -19,10 +19,12 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
chewie:
collection: collection:
flutter_sticky_header: flutter_sticky_header:
intl: intl:
photo_view: photo_view:
screen:
transparent_image: transparent_image:
dev_dependencies: dev_dependencies: