video: duration in thumbnail / info, player in fullscreen
This commit is contained in:
parent
49a28c6d09
commit
cc0283d393
7 changed files with 296 additions and 145 deletions
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,10 +55,14 @@ 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(
|
||||
final fontSize = (widget.extent / 8).roundToDouble();
|
||||
final iconSize = fontSize * 2;
|
||||
return DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: Colors.grey[200],
|
||||
fontSize: fontSize,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade700,
|
||||
|
@ -92,12 +95,12 @@ class ThumbnailState extends State<Thumbnail> {
|
|||
);
|
||||
}),
|
||||
),
|
||||
if (isVideo)
|
||||
Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: iconSize,
|
||||
),
|
||||
if (isGif)
|
||||
if (entry.isVideo)
|
||||
VideoTag(
|
||||
entry: entry,
|
||||
iconSize: iconSize,
|
||||
)
|
||||
else if (entry.isGif)
|
||||
Icon(
|
||||
Icons.gif,
|
||||
size: iconSize,
|
||||
|
@ -105,6 +108,42 @@ class ThumbnailState extends State<Thumbnail> {
|
|||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,7 +73,13 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
return WillPopScope(
|
||||
onWillPop: () {
|
||||
Screen.keepOn(false);
|
||||
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
|
||||
return Future.value(true);
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: Colors.white70)),
|
||||
SizedBox(width: 8),
|
||||
Text(title, style: TextStyle(fontSize: 18)),
|
||||
SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(title, style: TextStyle(fontSize: 20)),
|
||||
),
|
||||
Expanded(child: Divider(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -108,13 +108,12 @@ 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(
|
||||
return IgnorePointer(
|
||||
child: BlurredRect(
|
||||
child: Container(
|
||||
color: kOverlayBackground,
|
||||
child: IgnorePointer(
|
||||
child: Padding(
|
||||
padding: viewInsets + viewPadding.copyWith(top: 0),
|
||||
child: Container(
|
||||
child: Padding(
|
||||
padding: innerPadding,
|
||||
child: FutureBuilder(
|
||||
future: _detailLoader,
|
||||
|
@ -136,7 +135,6 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
28
pubspec.lock
28
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"
|
||||
|
|
|
@ -19,10 +19,12 @@ environment:
|
|||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
chewie:
|
||||
collection:
|
||||
flutter_sticky_header:
|
||||
intl:
|
||||
photo_view:
|
||||
screen:
|
||||
transparent_image:
|
||||
|
||||
dev_dependencies:
|
||||
|
|
Loading…
Reference in a new issue