custom video control overlay
This commit is contained in:
parent
6d12f3ddaa
commit
c4d44b49ea
8 changed files with 341 additions and 64 deletions
|
@ -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;
|
||||
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<FullscreenBody> with SingleTickerProvide
|
|||
Animation<double> _topOverlayScale;
|
||||
Animation<Offset> _bottomOverlayOffset;
|
||||
EdgeInsets _frozenViewInsets, _frozenViewPadding;
|
||||
List<Tuple2<String, VideoPlayerController>> _videoControllers = List();
|
||||
|
||||
ImageCollection get collection => widget.collection;
|
||||
|
||||
|
@ -117,6 +121,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
curve: Curves.easeOutQuart,
|
||||
));
|
||||
_overlayVisible.addListener(onOverlayVisibleChange);
|
||||
initVideoController();
|
||||
|
||||
Screen.keepOn(true);
|
||||
initOverlay();
|
||||
|
@ -132,12 +137,13 @@ class FullscreenBodyState extends State<FullscreenBody> 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<FullscreenBody> 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,7 +179,16 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
),
|
||||
],
|
||||
),
|
||||
if (_currentHorizontalPage != null && _currentVerticalPage == 0) ...[
|
||||
..._buildOverlay(entry)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _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,
|
||||
|
@ -183,7 +199,17 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
child: SlideTransition(
|
||||
child: Column(
|
||||
children: [
|
||||
if (videoController != null)
|
||||
VideoControlOverlay(
|
||||
entry: entry,
|
||||
controller: videoController,
|
||||
scale: _topOverlayScale,
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
),
|
||||
SlideTransition(
|
||||
position: _bottomOverlayOffset,
|
||||
child: FullscreenBottomOverlay(
|
||||
entries: entries,
|
||||
|
@ -192,11 +218,10 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
viewPadding: _frozenViewPadding,
|
||||
),
|
||||
),
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
goToVerticalPage(int page) {
|
||||
|
@ -344,6 +369,30 @@ class FullscreenBodyState extends State<FullscreenBody> 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<int> onPageChanged;
|
||||
final ValueChanged<PhotoViewScaleState> onScaleChanged;
|
||||
final List<Tuple2<String, VideoPlayerController>> 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<ImagePage> 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(),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
165
lib/widgets/fullscreen/overlay_video.dart
Normal file
165
lib/widgets/fullscreen/overlay_video.dart
Normal file
|
@ -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<double> 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<StatefulWidget> createState() => VideoControlOverlayState();
|
||||
}
|
||||
|
||||
class VideoControlOverlayState extends State<VideoControlOverlay> {
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
Animation<double> 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(() {});
|
||||
}
|
|
@ -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<StatefulWidget> createState() => AvesVideoState();
|
||||
}
|
||||
|
||||
class AvesVideoState extends State<AvesVideo> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue