diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index d9d111331..160d42ae2 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -45,6 +45,7 @@ class Settings extends ChangeNotifier { static const pinnedFiltersKey = 'pinned_filters'; // viewer + static const showOverlayMinimapKey = 'show_overlay_minimap'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; // info @@ -159,6 +160,10 @@ class Settings extends ChangeNotifier { // viewer + bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false); + + set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue); + bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, true); set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue); diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 6ee3f563f..7c8a0b4d7 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -10,6 +10,7 @@ import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart'; import 'package:aves/widgets/fullscreen/image_page.dart'; +import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart'; import 'package:aves/widgets/fullscreen/overlay/bottom.dart'; @@ -52,6 +53,7 @@ class FullscreenBodyState extends State with SingleTickerProvide EdgeInsets _frozenViewInsets, _frozenViewPadding; EntryActionDelegate _actionDelegate; final List> _videoControllers = []; + final List>> _viewStateNotifiers = []; CollectionLens get collection => widget.collection; @@ -97,7 +99,7 @@ class FullscreenBodyState extends State with SingleTickerProvide collection: collection, showInfo: () => _goToVerticalPage(infoPage), ); - _initVideoController(); + _initViewStateControllers(); _registerWidget(widget); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay()); @@ -169,6 +171,7 @@ class FullscreenBodyState extends State with SingleTickerProvide onHorizontalPageChanged: _onHorizontalPageChanged, onImageTap: () => _overlayVisible.value = !_overlayVisible.value, onImagePageRequested: () => _goToVerticalPage(imagePage), + viewStateNotifiers: _viewStateNotifiers, ), _buildTopOverlay(), _buildBottomOverlay(), @@ -183,6 +186,7 @@ class FullscreenBodyState extends State with SingleTickerProvide valueListenable: _entryNotifier, builder: (context, entry, child) { if (entry == null) return SizedBox.shrink(); + final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; return FullscreenTopOverlay( entry: entry, scale: _topOverlayScale, @@ -190,6 +194,7 @@ class FullscreenBodyState extends State with SingleTickerProvide viewInsets: _frozenViewInsets, viewPadding: _frozenViewPadding, onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action), + viewStateNotifier: viewStateNotifier, ); }, ); @@ -324,7 +329,7 @@ class FullscreenBodyState extends State with SingleTickerProvide if (_entryNotifier.value == newEntry) return; _entryNotifier.value = newEntry; _pauseVideoControllers(); - _initVideoController(); + _initViewStateControllers(); } void _onLeave() { @@ -381,33 +386,51 @@ class FullscreenBodyState extends State with SingleTickerProvide } } - // video controller + // state controllers/monitors - void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); - - Future _initVideoController() async { + void _initViewStateControllers() { final entry = _entryNotifier.value; - if (entry == null || !entry.isVideo) return; + if (entry == null) return; final uri = entry.uri; - var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null); - if (controllerEntry != null) { - _videoControllers.remove(controllerEntry); - } else { - // do not set data source of IjkMediaController here - controllerEntry = Tuple2(uri, IjkMediaController()); - } - _videoControllers.insert(0, controllerEntry); - while (_videoControllers.length > 3) { - _videoControllers.removeLast().item2.dispose(); + _initViewSpecificController>( + uri, + _viewStateNotifiers, + () => ValueNotifier(ViewState.zero), + (_) => _.dispose(), + ); + if (entry.isVideo) { + _initViewSpecificController( + uri, + _videoControllers, + () => IjkMediaController(), + (_) => _.dispose(), + ); } + setState(() {}); } + + void _initViewSpecificController(String uri, List> controllers, T Function() builder, void Function(T controller) disposer) { + var controller = controllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null); + if (controller != null) { + controllers.remove(controller); + } else { + controller = Tuple2(uri, builder()); + } + controllers.insert(0, controller); + while (controllers.length > 3) { + disposer?.call(controllers.removeLast().item2); + } + } + + void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); } class FullscreenVerticalPageView extends StatefulWidget { final CollectionLens collection; final ValueNotifier entryNotifier; + final List>> viewStateNotifiers; final List> videoControllers; final PageController horizontalPager, verticalPager; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; @@ -416,6 +439,7 @@ class FullscreenVerticalPageView extends StatefulWidget { const FullscreenVerticalPageView({ @required this.collection, @required this.entryNotifier, + @required this.viewStateNotifiers, @required this.videoControllers, @required this.verticalPager, @required this.horizontalPager, @@ -482,11 +506,13 @@ class _FullscreenVerticalPageViewState extends State pageController: widget.horizontalPager, onTap: widget.onImageTap, onPageChanged: widget.onHorizontalPageChanged, + viewStateNotifiers: widget.viewStateNotifiers, videoControllers: widget.videoControllers, ) : SingleImagePage( entry: entry, onTap: widget.onImageTap, + viewStateNotifiers: widget.viewStateNotifiers, videoControllers: widget.videoControllers, ), NotificationListener( diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index 82235c827..03ab97720 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -10,16 +10,16 @@ class MultiImagePage extends StatefulWidget { final CollectionLens collection; final PageController pageController; final ValueChanged onPageChanged; - final ValueChanged onScaleChanged; final VoidCallback onTap; + final List>> viewStateNotifiers; final List> videoControllers; const MultiImagePage({ this.collection, this.pageController, this.onPageChanged, - this.onScaleChanged, this.onTap, + this.viewStateNotifiers, this.videoControllers, }); @@ -49,8 +49,8 @@ class MultiImagePageState extends State with AutomaticKeepAliveC key: Key('imageview'), entry: entry, heroTag: widget.collection.heroTag(entry), - onScaleChanged: widget.onScaleChanged, onTap: widget.onTap, + viewStateNotifiers: widget.viewStateNotifiers, videoControllers: widget.videoControllers, ), ); @@ -66,14 +66,14 @@ class MultiImagePageState extends State with AutomaticKeepAliveC class SingleImagePage extends StatefulWidget { final ImageEntry entry; - final ValueChanged onScaleChanged; final VoidCallback onTap; + final List>> viewStateNotifiers; final List> videoControllers; const SingleImagePage({ this.entry, - this.onScaleChanged, this.onTap, + this.viewStateNotifiers, this.videoControllers, }); @@ -90,8 +90,8 @@ class SingleImagePageState extends State with AutomaticKeepAliv axis: [Axis.vertical], child: ImageView( entry: widget.entry, - onScaleChanged: widget.onScaleChanged, onTap: widget.onTap, + viewStateNotifiers: widget.viewStateNotifiers, videoControllers: widget.videoControllers, ), ); diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 8d62ffc16..79998c6bd 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/collection/empty.dart'; @@ -6,28 +8,56 @@ 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'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:photo_view/photo_view.dart'; import 'package:tuple/tuple.dart'; -class ImageView extends StatelessWidget { +class ImageView extends StatefulWidget { final ImageEntry entry; final Object heroTag; - final ValueChanged onScaleChanged; final VoidCallback onTap; + final List>> viewStateNotifiers; final List> videoControllers; const ImageView({ Key key, - this.entry, + @required this.entry, this.heroTag, - this.onScaleChanged, - this.onTap, - this.videoControllers, + @required this.onTap, + @required this.viewStateNotifiers, + @required this.videoControllers, }) : super(key: key); + @override + _ImageViewState createState() => _ImageViewState(); +} + +class _ImageViewState extends State { + final PhotoViewController _photoViewController = PhotoViewController(); + StreamSubscription _subscription; + + ImageEntry get entry => widget.entry; + + VoidCallback get onTap => widget.onTap; + + ValueNotifier get viewStateNotifier => widget.viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; + + @override + void initState() { + super.initState(); + _subscription = _photoViewController.outputStateStream.listen(_onViewChanged); + } + + @override + void dispose() { + _subscription.cancel(); + _subscription = null; + super.dispose(); + } + @override Widget build(BuildContext context) { const backgroundDecoration = BoxDecoration(color: Colors.transparent); @@ -35,7 +65,7 @@ class ImageView extends StatelessWidget { // 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; + final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; return PhotoView.customChild( child: videoController != null ? AvesVideo( @@ -44,7 +74,7 @@ class ImageView extends StatelessWidget { ) : SizedBox(), backgroundDecoration: backgroundDecoration, - scaleStateChangedCallback: onScaleChanged, + controller: _photoViewController, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, onTapUp: (tapContext, details, value) => onTap?.call(), @@ -88,7 +118,7 @@ class ImageView extends StatelessWidget { colorFilter: colorFilter, ), backgroundDecoration: backgroundDecoration, - scaleStateChangedCallback: onScaleChanged, + controller: _photoViewController, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, onTapUp: (tapContext, details, value) => onTap?.call(), @@ -113,7 +143,7 @@ class ImageView extends StatelessWidget { ), loadFailedChild: _buildError(), backgroundDecoration: backgroundDecoration, - scaleStateChangedCallback: onScaleChanged, + controller: _photoViewController, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, onTapUp: (tapContext, details, value) => onTap?.call(), @@ -123,9 +153,9 @@ class ImageView extends StatelessWidget { child = _buildError(); } - return heroTag != null + return widget.heroTag != null ? Hero( - tag: heroTag, + tag: widget.heroTag, transitionOnUserGestures: true, child: child, ) @@ -145,4 +175,22 @@ class ImageView extends StatelessWidget { ), ), ); + + void _onViewChanged(PhotoViewControllerValue v) { + viewStateNotifier?.value = ViewState(v.position, v.scale); + } +} + +class ViewState { + final Offset position; + final double scale; + + static const ViewState zero = ViewState(Offset(0.0, 0.0), 0); + + const ViewState(this.position, this.scale); + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{position=$position, scale=$scale}'; + } } diff --git a/lib/widgets/fullscreen/overlay/minimap.dart b/lib/widgets/fullscreen/overlay/minimap.dart new file mode 100644 index 000000000..f79e87838 --- /dev/null +++ b/lib/widgets/fullscreen/overlay/minimap.dart @@ -0,0 +1,101 @@ +import 'dart:math'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/fullscreen/image_view.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class Minimap extends StatelessWidget { + final ImageEntry entry; + final ValueNotifier viewStateNotifier; + final Size size; + + static const defaultSize = Size(96, 96); + + const Minimap({ + @required this.entry, + @required this.viewStateNotifier, + this.size = defaultSize, + }); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (context, mq) => mq.size, + builder: (context, mqSize, child) { + return AnimatedBuilder( + animation: viewStateNotifier, + builder: (context, child) { + return CustomPaint( + painter: MinimapPainter( + entrySize: entry.displaySize, + viewportSize: mqSize, + viewCenterOffset: viewStateNotifier.value.position, + viewScale: viewStateNotifier.value.scale, + minimapBorderColor: Colors.white30, + ), + size: size, + ); + }); + }); + } +} + +class MinimapPainter extends CustomPainter { + final Size entrySize, viewportSize; + final Offset viewCenterOffset; + final double viewScale; + final Color minimapBorderColor, viewportBorderColor; + + const MinimapPainter({ + @required this.entrySize, + @required this.viewportSize, + @required this.viewCenterOffset, + @required this.viewScale, + this.minimapBorderColor = Colors.white, + this.viewportBorderColor = Colors.white, + }); + + @override + void paint(Canvas canvas, Size size) { + final viewSize = entrySize * viewScale; + if (viewSize.isEmpty) return; + + final canvasScale = size.longestSide / viewSize.longestSide; + final scaledEntrySize = viewSize * canvasScale; + final scaledViewportSize = viewportSize * canvasScale; + // hide minimap when image is in full view + if (scaledViewportSize >= scaledEntrySize) return; + + final entryRect = Rect.fromCenter( + center: size.center(Offset.zero), + width: scaledEntrySize.width, + height: scaledEntrySize.height, + ); + final viewportRect = Rect.fromCenter( + center: size.center(Offset.zero) - viewCenterOffset * canvasScale, + width: min(scaledEntrySize.width, scaledViewportSize.width), + height: min(scaledEntrySize.height, scaledViewportSize.height), + ); + + canvas.translate((entryRect.width - size.width) / 2, (entryRect.height - size.height) / 2); + + final fill = Paint() + ..style = PaintingStyle.fill + ..color = Color(0x33000000); + final minimapStroke = Paint() + ..style = PaintingStyle.stroke + ..color = minimapBorderColor; + final viewportStroke = Paint() + ..style = PaintingStyle.stroke + ..color = viewportBorderColor; + + canvas.drawRect(viewportRect, fill); + canvas.drawRect(entryRect, fill); + canvas.drawRect(entryRect, minimapStroke); + canvas.drawRect(viewportRect, viewportStroke); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => true; +} diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index a67555a03..bfd209e36 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -2,11 +2,14 @@ import 'dart:math'; import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/menu_row.dart'; +import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; +import 'package:aves/widgets/fullscreen/overlay/minimap.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -18,6 +21,7 @@ class FullscreenTopOverlay extends StatelessWidget { final EdgeInsets viewInsets, viewPadding; final Function(EntryAction value) onActionSelected; final bool canToggleFavourite; + final ValueNotifier viewStateNotifier; static const double padding = 8; @@ -33,6 +37,7 @@ class FullscreenTopOverlay extends StatelessWidget { @required this.viewInsets, @required this.viewPadding, @required this.onActionSelected, + this.viewStateNotifier, }) : super(key: key); @override @@ -58,8 +63,7 @@ class FullscreenTopOverlay extends StatelessWidget { ].where(_canDo).take(quickActionCount).toList(); final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList(); final externalAppActions = EntryActions.externalApp.where(_canDo).toList(); - - return _TopOverlayRow( + final buttonRow = _TopOverlayRow( quickActions: quickActions, inAppActions: inAppActions, externalAppActions: externalAppActions, @@ -67,6 +71,23 @@ class FullscreenTopOverlay extends StatelessWidget { entry: entry, onActionSelected: onActionSelected, ); + + return settings.showOverlayMinimap && viewStateNotifier != null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buttonRow, + SizedBox(height: 8), + FadeTransition( + opacity: scale, + child: Minimap( + entry: entry, + viewStateNotifier: viewStateNotifier, + ), + ) + ], + ) + : buttonRow; }, ), ), diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 8a63f6a04..e10355fb1 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -177,6 +177,11 @@ class _SettingsPageState extends State { title: 'Viewer', expandedNotifier: _expandedNotifier, children: [ + SwitchListTile( + value: settings.showOverlayMinimap, + onChanged: (v) => settings.showOverlayMinimap = v, + title: Text('Show minimap'), + ), SwitchListTile( value: settings.showOverlayShootingDetails, onChanged: (v) => settings.showOverlayShootingDetails = v,