diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart index 043434a51..286f204a9 100644 --- a/lib/widgets/album/thumbnail.dart +++ b/lib/widgets/album/thumbnail.dart @@ -1,11 +1,9 @@ import 'dart:math'; -import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/image_file_service.dart'; import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/image_preview.dart'; import 'package:flutter/material.dart'; -import 'package:transparent_image/transparent_image.dart'; class Thumbnail extends StatelessWidget { final ImageEntry entry; @@ -21,6 +19,27 @@ class Thumbnail extends StatelessWidget { @override Widget build(BuildContext context) { + final image = ImagePreview( + entry: entry, + width: extent, + height: extent, + devicePixelRatio: devicePixelRatio, + builder: (bytes) { + return Hero( + tag: entry.uri, + child: LayoutBuilder(builder: (context, constraints) { + final dim = min(constraints.maxWidth, constraints.maxHeight); + return Image.memory( + bytes, + width: dim, + height: dim, + fit: BoxFit.cover, + ); + }), + ); + }, + ); + final icons = _buildOverlayIcons(); return Container( decoration: BoxDecoration( border: Border.all( @@ -28,118 +47,43 @@ class Thumbnail extends StatelessWidget { width: 0.5, ), ), - child: Stack( - alignment: AlignmentDirectional.bottomStart, - children: [ - ThumbnailImage( - entry: entry, - extent: extent, - devicePixelRatio: devicePixelRatio, - ), - _buildOverlayIcons(), - ], - ), + child: icons != null + ? Stack( + alignment: AlignmentDirectional.bottomStart, + children: [ + image, + icons, + ], + ) + : image, ); } Widget _buildOverlayIcons() { final fontSize = min(14.0, (extent / 8).roundToDouble()); final iconSize = fontSize * 2; - return DefaultTextStyle( - style: TextStyle( - color: Colors.grey[200], - fontSize: fontSize, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (entry.hasGps) GpsIcon(iconSize: iconSize), - if (entry.isGif) - GifIcon(iconSize: iconSize) - else if (entry.isVideo) - VideoIcon( - entry: entry, - iconSize: iconSize, + final icons = [ + if (entry.hasGps) GpsIcon(iconSize: iconSize), + if (entry.isGif) + GifIcon(iconSize: iconSize) + else if (entry.isVideo) + VideoIcon( + entry: entry, + iconSize: iconSize, + ), + ]; + return icons.isNotEmpty + ? DefaultTextStyle( + style: TextStyle( + color: Colors.grey[200], + fontSize: fontSize, ), - ], - ), - ); - } -} - -class ThumbnailImage extends StatefulWidget { - final ImageEntry entry; - final double extent; - final double devicePixelRatio; - - const ThumbnailImage({ - Key key, - @required this.entry, - @required this.extent, - @required this.devicePixelRatio, - }) : super(key: key); - - @override - State createState() => ThumbnailImageState(); -} - -class ThumbnailImageState extends State { - Future _byteLoader; - Listenable _entryChangeNotifier; - - ImageEntry get entry => widget.entry; - - String get uri => widget.entry.uri; - - @override - void initState() { - super.initState(); - _entryChangeNotifier = Listenable.merge([entry.imageChangeNotifier, entry.metadataChangeNotifier]); - _entryChangeNotifier.addListener(onEntryChange); - initByteLoader(); - } - - @override - void didUpdateWidget(ThumbnailImage oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.extent == oldWidget.extent && uri == oldWidget.entry.uri && widget.entry.width == oldWidget.entry.width && widget.entry.height == oldWidget.entry.height && widget.entry.orientationDegrees == oldWidget.entry.orientationDegrees) return; - initByteLoader(); - } - - initByteLoader() { - final dim = (widget.extent * widget.devicePixelRatio).round(); - _byteLoader = ImageFileService.getImageBytes(widget.entry, dim, dim); - } - - onEntryChange() => setState(() => initByteLoader()); - - @override - void dispose() { - _entryChangeNotifier.removeListener(onEntryChange); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _byteLoader, - builder: (futureContext, AsyncSnapshot snapshot) { - final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage; - return bytes.length > 0 - ? Hero( - tag: entry.uri, - child: LayoutBuilder(builder: (context, constraints) { - final dim = min(constraints.maxWidth, constraints.maxHeight); - return Image.memory( - bytes, - width: dim, - height: dim, - fit: BoxFit.cover, - ); - }), - ) - : Icon(Icons.error); - }); + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: icons, + ), + ) + : null; } } diff --git a/lib/widgets/common/image_preview.dart b/lib/widgets/common/image_preview.dart new file mode 100644 index 000000000..2a30fa5d6 --- /dev/null +++ b/lib/widgets/common/image_preview.dart @@ -0,0 +1,72 @@ +import 'dart:typed_data'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/image_file_service.dart'; +import 'package:flutter/material.dart'; +import 'package:transparent_image/transparent_image.dart'; + +class ImagePreview extends StatefulWidget { + final ImageEntry entry; + final double width, height, devicePixelRatio; + final Widget Function(Uint8List bytes) builder; + + const ImagePreview({ + Key key, + @required this.entry, + @required this.width, + @required this.height, + @required this.devicePixelRatio, + @required this.builder, + }) : super(key: key); + + @override + State createState() => ImagePreviewState(); +} + +class ImagePreviewState extends State { + Future _byteLoader; + Listenable _entryChangeNotifier; + + ImageEntry get entry => widget.entry; + + String get uri => widget.entry.uri; + + @override + void initState() { + super.initState(); + _entryChangeNotifier = Listenable.merge([entry.imageChangeNotifier, entry.metadataChangeNotifier]); + _entryChangeNotifier.addListener(onEntryChange); + initByteLoader(); + } + + @override + void didUpdateWidget(ImagePreview old) { + super.didUpdateWidget(old); + if (widget.width == old.width && widget.height == old.height && uri == old.entry.uri && widget.entry.width == old.entry.width && widget.entry.height == old.entry.height && widget.entry.orientationDegrees == old.entry.orientationDegrees) return; + initByteLoader(); + } + + initByteLoader() { + final width = (widget.width * widget.devicePixelRatio).round(); + final height = (widget.height * widget.devicePixelRatio).round(); + _byteLoader = ImageFileService.getImageBytes(widget.entry, width, height); + } + + onEntryChange() => setState(() => initByteLoader()); + + @override + void dispose() { + _entryChangeNotifier.removeListener(onEntryChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _byteLoader, + builder: (futureContext, AsyncSnapshot snapshot) { + final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage; + return bytes.length > 0 ? widget.builder(bytes) : Icon(Icons.error); + }); + } +} diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/fullscreen/overlay/video.dart index eafa6e183..4a1f70a04 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/fullscreen/overlay/video.dart @@ -25,9 +25,10 @@ class VideoControlOverlay extends StatefulWidget { State createState() => VideoControlOverlayState(); } -class VideoControlOverlayState extends State { +class VideoControlOverlayState extends State with SingleTickerProviderStateMixin { final GlobalKey _progressBarKey = GlobalKey(); bool _playingOnDragStart = false; + AnimationController _playPauseAnimation; ImageEntry get entry => widget.entry; @@ -42,6 +43,10 @@ class VideoControlOverlayState extends State { @override void initState() { super.initState(); + _playPauseAnimation = AnimationController( + duration: Duration(milliseconds: 300), + vsync: this, + ); registerWidget(widget); _onValueChange(); } @@ -56,6 +61,7 @@ class VideoControlOverlayState extends State { @override void dispose() { unregisterWidget(widget); + _playPauseAnimation.dispose(); super.dispose(); } @@ -75,40 +81,40 @@ class VideoControlOverlayState extends State { 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: [ + child: SizedBox( + width: mediaQuery.size.width - safePadding.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: value.hasError + ? [ + OverlayButton( + scale: scale, + child: IconButton( + icon: Icon(Icons.open_in_new), + onPressed: () => AndroidAppService.open(entry.uri, entry.mimeType), + tooltip: 'Open', + ), + ), + ] + : [ Expanded( child: _buildProgressBar(), ), SizedBox(width: 8), 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', - ), + child: IconButton( + icon: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: _playPauseAnimation, + ), + onPressed: () => _playPause(), + tooltip: 'Play', + ), ), ], - ), - ), + ), + ), ); } @@ -160,20 +166,31 @@ class VideoControlOverlayState extends State { ); } - _onValueChange() => setState(() {}); + _onValueChange() { + setState(() {}); + updatePlayPauseIcon(); + } _playPause() async { if (value.isPlaying) { controller.pause(); } else { - if (!value.initialized) { - await controller.initialize(); - } + if (!value.initialized) await controller.initialize(); controller.play(); } setState(() {}); } + updatePlayPauseIcon() { + final isPlaying = value.isPlaying; + final status = _playPauseAnimation.status; + if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) { + _playPauseAnimation.forward(); + } else if (!isPlaying && status != AnimationStatus.reverse && status != AnimationStatus.dismissed) { + _playPauseAnimation.reverse(); + } + } + _seek(Offset globalPosition) { final keyContext = _progressBarKey.currentContext; final RenderBox box = keyContext.findRenderObject(); diff --git a/lib/widgets/fullscreen/video.dart b/lib/widgets/fullscreen/video.dart index d23e7de05..c7806a0d7 100644 --- a/lib/widgets/fullscreen/video.dart +++ b/lib/widgets/fullscreen/video.dart @@ -1,4 +1,7 @@ +import 'dart:math'; + import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/common/image_preview.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; @@ -52,13 +55,17 @@ class AvesVideoState extends State { @override Widget build(BuildContext context) { if (value == null) return SizedBox(); - if (value.hasError) - return Center( - child: Icon( - Icons.error_outline, - size: 32, - ), + if (value.hasError) { + final mediaQuery = MediaQuery.of(context); + final width = min(mediaQuery.size.width, entry.width.toDouble()); + return ImagePreview( + entry: entry, + width: width, + height: width / entry.aspectRatio, + devicePixelRatio: mediaQuery.devicePixelRatio, + builder: (bytes) => Image.memory(bytes), ); + } return Center( child: AspectRatio( aspectRatio: entry.aspectRatio,