diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 91105133b..4a08c4d05 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -5,6 +5,7 @@ import 'package:aves/model/metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:geocoder/geocoder.dart'; import 'package:path/path.dart'; import 'package:tuple/tuple.dart'; @@ -118,14 +119,15 @@ class ImageEntry { bool get canRotate => canEdit && (mimeType == MimeTypes.MIME_JPEG || mimeType == MimeTypes.MIME_PNG); - double get aspectRatio { + bool get rotated => ((isVideo && isCatalogued) ? catalogMetadata.videoRotation : orientationDegrees) % 180 == 90; + + double get displayAspectRatio { if (width == 0 || height == 0) return 1; - if (isVideo && isCatalogued) { - if (catalogMetadata.videoRotation % 180 == 90) return height / width; - } - return width / height; + return rotated ? height / width : width / height; } + Size get displaySize => rotated ? Size(height.toDouble(), width.toDouble()) : Size(width.toDouble(), height.toDouble()); + int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null; DateTime get bestDate { diff --git a/lib/widgets/album/collection_drawer.dart b/lib/widgets/album/collection_drawer.dart index 5e53a68a7..df8baaad6 100644 --- a/lib/widgets/album/collection_drawer.dart +++ b/lib/widgets/album/collection_drawer.dart @@ -249,17 +249,7 @@ class _CollectionDrawerState extends State { child: ListTile( leading: const Icon(OMIcons.whatshot), title: const Text('Debug'), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => DebugPage( - source: source, - ), - ), - ); - }, + onTap: () => _goToDebug(context), ), ), ], @@ -285,6 +275,18 @@ class _CollectionDrawerState extends State { ), ); } + + void _goToDebug(BuildContext context) { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DebugPage( + source: source, + ), + ), + ); + } } class _FilteredCollectionNavTile extends StatelessWidget { @@ -311,21 +313,23 @@ class _FilteredCollectionNavTile extends StatelessWidget { leading: leading, title: Text(title), dense: dense, - onTap: () { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (context) => CollectionPage(CollectionLens( - source: source, - filters: [filter], - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, - )), - ), - (route) => false, - ); - }, + onTap: () => _goToCollection(context), ), ); } + + void _goToCollection(BuildContext context) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => CollectionPage(CollectionLens( + source: source, + filters: [filter], + groupFactor: settings.collectionGroupFactor, + sortFactor: settings.collectionSortFactor, + )), + ), + (route) => false, + ); + } } diff --git a/lib/widgets/album/collection_section.dart b/lib/widgets/album/collection_section.dart index 74632b7e7..2585a08d8 100644 --- a/lib/widgets/album/collection_section.dart +++ b/lib/widgets/album/collection_section.dart @@ -85,7 +85,7 @@ class GridThumbnail extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( key: ValueKey(entry.uri), - onTap: () => _showFullscreen(context), + onTap: () => _goToFullscreen(context), child: Selector( selector: (c, mq) => mq.size.width, builder: (c, mqWidth, child) { @@ -102,7 +102,7 @@ class GridThumbnail extends StatelessWidget { ); } - void _showFullscreen(BuildContext context) { + void _goToFullscreen(BuildContext context) { Navigator.push( context, TransparentMaterialPageRoute( diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart index c5b77e3ee..dfe44f529 100644 --- a/lib/widgets/album/thumbnail.dart +++ b/lib/widgets/album/thumbnail.dart @@ -5,6 +5,7 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; +import 'package:aves/widgets/common/transition_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -59,17 +60,11 @@ class Thumbnail extends StatelessWidget { ? image : Hero( tag: heroTag, - flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) { - // use LayoutBuilder only during hero animation - return LayoutBuilder(builder: (context, constraints) { - final dim = min(constraints.maxWidth, constraints.maxHeight); - return Image( - image: provider, - width: dim, - height: dim, - fit: BoxFit.cover, - ); - }); + flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { + return TransitionImage( + image: provider, + animation: animation, + ); }, child: image, ); diff --git a/lib/widgets/common/transition_image.dart b/lib/widgets/common/transition_image.dart new file mode 100644 index 000000000..000c0bf26 --- /dev/null +++ b/lib/widgets/common/transition_image.dart @@ -0,0 +1,191 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +// adapted from `RawImage`, `paintImage()` from `DecorationImagePainter`, etc. +// to transition between 2 different fits during hero animation: +// - BoxFit.cover at t=0 +// - BoxFit.contain at t=1 + +class TransitionImage extends StatefulWidget { + final ImageProvider image; + final double width, height; + final ValueListenable animation; + final gaplessPlayback = false; + + const TransitionImage({ + @required this.image, + @required this.animation, + this.width, + this.height, + }); + + @override + _TransitionImageState createState() => _TransitionImageState(); +} + +class _TransitionImageState extends State { + ImageStream _imageStream; + ImageInfo _imageInfo; + bool _isListeningToStream = false; + int _frameNumber; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + assert(_imageStream != null); + _stopListeningToStream(); + super.dispose(); + } + + @override + void didChangeDependencies() { + _resolveImage(); + + if (TickerMode.of(context)) { + _listenToStream(); + } else { + _stopListeningToStream(); + } + + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(TransitionImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (_isListeningToStream) { + _imageStream.removeListener(_getListener()); + _imageStream.addListener(_getListener()); + } + if (widget.image != oldWidget.image) _resolveImage(); + } + + @override + void reassemble() { + _resolveImage(); // in case the image cache was flushed + super.reassemble(); + } + + void _resolveImage() { + final provider = widget.image; + final newStream = provider.resolve(createLocalImageConfiguration( + context, + size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null, + )); + assert(newStream != null); + _updateSourceStream(newStream); + } + + ImageStreamListener _getListener() { + return ImageStreamListener( + _handleImageFrame, + onChunk: null, + ); + } + + void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) { + setState(() { + _imageInfo = imageInfo; + _frameNumber = _frameNumber == null ? 0 : _frameNumber + 1; + }); + } + + // Updates _imageStream to newStream, and moves the stream listener + // registration from the old stream to the new stream (if a listener was + // registered). + void _updateSourceStream(ImageStream newStream) { + if (_imageStream?.key == newStream?.key) return; + + if (_isListeningToStream) _imageStream.removeListener(_getListener()); + + if (!widget.gaplessPlayback) { + setState(() { + _imageInfo = null; + }); + } + + setState(() { + _frameNumber = null; + }); + + _imageStream = newStream; + if (_isListeningToStream) _imageStream.addListener(_getListener()); + } + + void _listenToStream() { + if (_isListeningToStream) return; + _imageStream.addListener(_getListener()); + _isListeningToStream = true; + } + + void _stopListeningToStream() { + if (!_isListeningToStream) return; + _imageStream.removeListener(_getListener()); + _isListeningToStream = false; + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.animation, + builder: (context, t, child) => CustomPaint( + painter: _TransitionImagePainter( + // AssetImage(name).resolve(configuration) + image: _imageInfo?.image, + scale: _imageInfo?.scale ?? 1.0, + t: t, + ), + ), + ); + } +} + +class _TransitionImagePainter extends CustomPainter { + final ui.Image image; + final double scale; + final double t; + + const _TransitionImagePainter({ + @required this.image, + @required this.scale, + @required this.t, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..isAntiAlias = false + ..filterQuality = FilterQuality.low; + const alignment = Alignment.center; + + final rect = ui.Rect.fromLTWH(0, 0, size.width, size.height); + final inputSize = Size(image.width.toDouble(), image.height.toDouble()); + final outputSize = rect.size; + + final coverSizes = applyBoxFit(BoxFit.cover, inputSize / scale, size); + final containSizes = applyBoxFit(BoxFit.contain, inputSize / scale, size); + final sourceSize = Size.lerp(coverSizes.source, containSizes.source, t) * scale; + final destinationSize = Size.lerp(coverSizes.destination, containSizes.destination, t); + + final halfWidthDelta = (outputSize.width - destinationSize.width) / 2.0; + final halfHeightDelta = (outputSize.height - destinationSize.height) / 2.0; + final dx = halfWidthDelta + alignment.x * halfWidthDelta; + final dy = halfHeightDelta + alignment.y * halfHeightDelta; + final destinationPosition = rect.topLeft.translate(dx, dy); + final destinationRect = destinationPosition & destinationSize; + final sourceRect = alignment.inscribe( + sourceSize, + Offset.zero & inputSize, + ); + canvas.drawImageRect(image, sourceRect, destinationRect, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => true; +} diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 8eefe80e1..ef57e061b 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -1,5 +1,6 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/fullscreen/video_view.dart'; @@ -28,6 +29,8 @@ class ImageView extends StatelessWidget { Widget build(BuildContext context) { const backgroundDecoration = BoxDecoration(color: Colors.transparent); + // no hero for videos, as a typical video first frame is different from its thumbnail + if (entry.isVideo) { final videoController = videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; return PhotoView.customChild( @@ -38,7 +41,6 @@ class ImageView extends StatelessWidget { ) : const SizedBox(), backgroundDecoration: backgroundDecoration, - // no hero as most videos fullscreen image is different from its thumbnail scaleStateChangedCallback: onScaleChanged, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, @@ -46,53 +48,61 @@ class ImageView extends StatelessWidget { ); } - final placeholderBuilder = (context) => const Center( - child: SizedBox( - width: 64, - height: 64, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ); - final heroAttributes = heroTag != null - ? PhotoViewHeroAttributes( - tag: heroTag, - transitionOnUserGestures: true, - ) - : null; + // if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`, + // the route transition becomes visible if the final is loaded before the hero animation is done. + // if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided, + // there's a black frame between the hero animation and the final image, even when it's cached. + + final loadingBuilder = (context) => Image( + image: ThumbnailProvider( + entry: entry, + extent: Constants.thumbnailCacheExtent, + ), + fit: BoxFit.contain, + ); + + Widget child; if (entry.isSvg) { - return PhotoView.customChild( + child = PhotoView.customChild( child: SvgPicture( UriPicture( uri: entry.uri, mimeType: entry.mimeType, colorFilter: Constants.svgColorFilter, ), - placeholderBuilder: placeholderBuilder, + placeholderBuilder: loadingBuilder, ), backgroundDecoration: backgroundDecoration, - heroAttributes: heroAttributes, scaleStateChangedCallback: onScaleChanged, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, onTapUp: (tapContext, details, value) => onTap?.call(), ); + } else { + child = PhotoView( + // key includes size and orientation to refresh when the image is rotated + key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'), + imageProvider: UriImage( + uri: entry.uri, + mimeType: entry.mimeType, + ), + loadingBuilder: (context, event) => loadingBuilder(context), + backgroundDecoration: backgroundDecoration, + scaleStateChangedCallback: onScaleChanged, + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + onTapUp: (tapContext, details, value) => onTap?.call(), + filterQuality: FilterQuality.low, + ); } - return PhotoView( - // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'), - imageProvider: UriImage(uri: entry.uri, mimeType: entry.mimeType), - loadingBuilder: (context, event) => placeholderBuilder(context), - backgroundDecoration: backgroundDecoration, - heroAttributes: heroAttributes, - scaleStateChangedCallback: onScaleChanged, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => onTap?.call(), - filterQuality: FilterQuality.low, - ); + return heroTag != null + ? Hero( + tag: heroTag, + transitionOnUserGestures: true, + child: child, + ) + : child; } } diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index 44be7ae30..c425eecb5 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -75,14 +75,14 @@ class InfoPageState extends State { entry: entry, showTitle: !locationAtTop, visibleNotifier: widget.visibleNotifier, - onFilter: _goToFilteredCollection, + onFilter: _goToCollection, ); final basicAndLocationSliver = locationAtTop ? SliverToBoxAdapter( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToFilteredCollection)), + Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToCollection)), const SizedBox(width: 8), Expanded(child: locationSection), ], @@ -91,7 +91,7 @@ class InfoPageState extends State { : SliverList( delegate: SliverChildListDelegate.fixed( [ - BasicSection(entry: entry, collection: collection, onFilter: _goToFilteredCollection), + BasicSection(entry: entry, collection: collection, onFilter: _goToCollection), locationSection, ], ), @@ -153,7 +153,7 @@ class InfoPageState extends State { ); } - void _goToFilteredCollection(CollectionFilter filter) { + void _goToCollection(CollectionFilter filter) { if (collection == null) return; Navigator.pushAndRemoveUntil( context, diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index 39661e455..309cd3745 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -64,7 +64,7 @@ class AvesVideoState extends State { } return Center( child: AspectRatio( - aspectRatio: entry.aspectRatio, + aspectRatio: entry.displayAspectRatio, child: VideoPlayer(widget.controller), ), ); diff --git a/lib/widgets/stats.dart b/lib/widgets/stats.dart index 43a4c1788..b960d9749 100644 --- a/lib/widgets/stats.dart +++ b/lib/widgets/stats.dart @@ -223,7 +223,7 @@ class StatsPage extends StatelessWidget { alignment: AlignmentDirectional.centerStart, child: AvesFilterChip( filter: filterBuilder(label), - onPressed: (filter) => _goToFilteredCollection(context, filter), + onPressed: (filter) => _goToCollection(context, filter), ), ), LinearPercentIndicator( @@ -253,7 +253,7 @@ class StatsPage extends StatelessWidget { ]; } - void _goToFilteredCollection(BuildContext context, CollectionFilter filter) { + void _goToCollection(BuildContext context, CollectionFilter filter) { if (collection == null) return; Navigator.pushAndRemoveUntil( context,