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);
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';
}
}

View file

@ -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<Thumbnail> {
Future<Uint8List> _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<Thumbnail> {
@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<Uint8List> 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<Uint8List> 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(),
)
],
),
);
}
}

View file

@ -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<ImageEntry> entries;
@ -50,6 +53,8 @@ class FullscreenPageState extends State<FullscreenPage> 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<FullscreenPage> 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<FullscreenPage> with SingleTickerProvide
// ],
// ),
// ),
),
);
}
@ -162,9 +174,9 @@ class FullscreenPageState extends State<FullscreenPage> 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<ImagePage> 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<ImagePage> 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<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) {
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<InfoPage> {
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<InfoPage> {
return Text(snapshot.error);
}
if (snapshot.connectionState != ConnectionState.done) {
return CircularProgressIndicator();
return SizedBox.shrink();
}
final metadataMap = snapshot.data.cast<String, Map>();
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)),
],
);
}
}

View file

@ -108,31 +108,29 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
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<Map> 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<Map> 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,
);
},
),
),
),

View file

@ -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"

View file

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