diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b3df8ae..552d1cfb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ All notable changes to this project will be documented in this file. - Stats: top albums - Stats: open full top listings - Video: option for muted auto play -- Slideshow: option for no transition +- Slideshow / Screen saver: option for no transition +- Slideshow / Screen saver: animated zoom effect - Widget: tap action setting - Wallpaper: scroll effect option diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ffbb38eea..2ff278c76 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -715,6 +715,7 @@ "settingsSlideshowRepeat": "Repeat", "settingsSlideshowShuffle": "Shuffle", "settingsSlideshowFillScreen": "Fill screen", + "settingsSlideshowAnimatedZoomEffect": "Animated zoom effect", "settingsSlideshowTransitionTile": "Transition", "settingsSlideshowTransitionDialogTitle": "Transition", "settingsSlideshowIntervalTile": "Interval", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c7a632f7b..315bfb909 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -534,6 +534,7 @@ "settingsSlideshowRepeat": "Répéter", "settingsSlideshowShuffle": "Aléatoire", "settingsSlideshowFillScreen": "Remplir l’écran", + "settingsSlideshowAnimatedZoomEffect": "Effet de zoom animé", "settingsSlideshowTransitionTile": "Transition", "settingsSlideshowTransitionDialogTitle": "Transition", "settingsSlideshowIntervalTile": "Intervalle", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9f0ab9950..accee2af5 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -534,6 +534,7 @@ "settingsSlideshowRepeat": "반복", "settingsSlideshowShuffle": "순서섞기", "settingsSlideshowFillScreen": "화면 채우기", + "settingsSlideshowAnimatedZoomEffect": "애니메이션 확대/축소 효과", "settingsSlideshowTransitionTile": "전환 효과", "settingsSlideshowTransitionDialogTitle": "전환 효과", "settingsSlideshowIntervalTile": "교체 주기", diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index a4e8b8808..08b79553b 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -129,6 +129,7 @@ class SettingsDefaults { static const slideshowRepeat = false; static const slideshowShuffle = false; static const slideshowFillScreen = false; + static const slideshowAnimatedZoomEffect = true; static const slideshowTransition = ViewerTransition.fade; static const slideshowVideoPlayback = SlideshowVideoPlayback.playMuted; static const slideshowInterval = SlideshowInterval.s5; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 6cd843e1a..71a40dcca 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -151,6 +151,7 @@ class Settings extends ChangeNotifier { // screen saver static const screenSaverFillScreenKey = 'screen_saver_fill_screen'; + static const screenSaverAnimatedZoomEffectKey = 'screen_saver_animated_zoom_effect'; static const screenSaverTransitionKey = 'screen_saver_transition'; static const screenSaverVideoPlaybackKey = 'screen_saver_video_playback'; static const screenSaverIntervalKey = 'screen_saver_interval'; @@ -160,6 +161,7 @@ class Settings extends ChangeNotifier { static const slideshowRepeatKey = 'slideshow_loop'; static const slideshowShuffleKey = 'slideshow_shuffle'; static const slideshowFillScreenKey = 'slideshow_fill_screen'; + static const slideshowAnimatedZoomEffectKey = 'slideshow_animated_zoom_effect'; static const slideshowTransitionKey = 'slideshow_transition'; static const slideshowVideoPlaybackKey = 'slideshow_video_playback'; static const slideshowIntervalKey = 'slideshow_interval'; @@ -649,6 +651,10 @@ class Settings extends ChangeNotifier { set screenSaverFillScreen(bool newValue) => setAndNotify(screenSaverFillScreenKey, newValue); + bool get screenSaverAnimatedZoomEffect => getBoolOrDefault(screenSaverAnimatedZoomEffectKey, SettingsDefaults.slideshowAnimatedZoomEffect); + + set screenSaverAnimatedZoomEffect(bool newValue) => setAndNotify(screenSaverAnimatedZoomEffectKey, newValue); + ViewerTransition get screenSaverTransition => getEnumOrDefault(screenSaverTransitionKey, SettingsDefaults.slideshowTransition, ViewerTransition.values); set screenSaverTransition(ViewerTransition newValue) => setAndNotify(screenSaverTransitionKey, newValue.toString()); @@ -679,6 +685,10 @@ class Settings extends ChangeNotifier { set slideshowFillScreen(bool newValue) => setAndNotify(slideshowFillScreenKey, newValue); + bool get slideshowAnimatedZoomEffect => getBoolOrDefault(slideshowAnimatedZoomEffectKey, SettingsDefaults.slideshowAnimatedZoomEffect); + + set slideshowAnimatedZoomEffect(bool newValue) => setAndNotify(slideshowAnimatedZoomEffectKey, newValue); + ViewerTransition get slideshowTransition => getEnumOrDefault(slideshowTransitionKey, SettingsDefaults.slideshowTransition, ViewerTransition.values); set slideshowTransition(ViewerTransition newValue) => setAndNotify(slideshowTransitionKey, newValue.toString()); @@ -880,9 +890,11 @@ class Settings extends ChangeNotifier { case saveSearchHistoryKey: case filePickerShowHiddenFilesKey: case screenSaverFillScreenKey: + case screenSaverAnimatedZoomEffectKey: case slideshowRepeatKey: case slideshowShuffleKey: case slideshowFillScreenKey: + case slideshowAnimatedZoomEffectKey: if (newValue is bool) { settingsStore.setBool(key, newValue); } else { diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index cb291c8f1..226f1b3b8 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -41,6 +41,7 @@ class Durations { static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150); static const viewerVideoPlayerTransition = Duration(milliseconds: 500); static const viewerActionFeedbackAnimation = Duration(milliseconds: 600); + static const viewerHorizontalPageAnimation = Duration(seconds: 1); // info animations static const mapStyleSwitchAnimation = Duration(milliseconds: 300); diff --git a/lib/widgets/common/magnifier/scale/scale_boundaries.dart b/lib/widgets/common/magnifier/scale/scale_boundaries.dart index e2190afac..bba15200e 100644 --- a/lib/widgets/common/magnifier/scale/scale_boundaries.dart +++ b/lib/widgets/common/magnifier/scale/scale_boundaries.dart @@ -45,7 +45,7 @@ class ScaleBoundaries extends Equatable { ); } - double _scaleForLevel(ScaleLevel level) { + double scaleForLevel(ScaleLevel level) { final factor = level.factor; switch (level.ref) { case ScaleReference.contained: @@ -61,18 +61,18 @@ class ScaleBoundaries extends Equatable { double get originalScale => 1.0 / window.devicePixelRatio; double get minScale => { - _scaleForLevel(_minScale), + scaleForLevel(_minScale), _allowOriginalScaleBeyondRange ? originalScale : double.infinity, initialScale, }.fold(double.infinity, min); double get maxScale => { - _scaleForLevel(_maxScale), + scaleForLevel(_maxScale), _allowOriginalScaleBeyondRange ? originalScale : double.negativeInfinity, initialScale, }.fold(0, max); - double get initialScale => _scaleForLevel(_initialScale); + double get initialScale => scaleForLevel(_initialScale); Offset get _viewportCenter => viewportSize.center(Offset.zero); diff --git a/lib/widgets/settings/screen_saver_settings_page.dart b/lib/widgets/settings/screen_saver_settings_page.dart index e7618c257..8e1fb6db6 100644 --- a/lib/widgets/settings/screen_saver_settings_page.dart +++ b/lib/widgets/settings/screen_saver_settings_page.dart @@ -32,6 +32,11 @@ class ScreenSaverSettingsPage extends StatelessWidget { onChanged: (v) => settings.screenSaverFillScreen = v, title: context.l10n.settingsSlideshowFillScreen, ), + SettingsSwitchListTile( + selector: (context, s) => s.screenSaverAnimatedZoomEffect, + onChanged: (v) => settings.screenSaverAnimatedZoomEffect = v, + title: context.l10n.settingsSlideshowAnimatedZoomEffect, + ), SettingsSelectionListTile( values: ViewerTransition.values, getName: (context, v) => v.getName(context), diff --git a/lib/widgets/settings/viewer/slideshow.dart b/lib/widgets/settings/viewer/slideshow.dart index a266d13f3..e81571229 100644 --- a/lib/widgets/settings/viewer/slideshow.dart +++ b/lib/widgets/settings/viewer/slideshow.dart @@ -36,6 +36,11 @@ class ViewerSlideshowPage extends StatelessWidget { onChanged: (v) => settings.slideshowFillScreen = v, title: context.l10n.settingsSlideshowFillScreen, ), + SettingsSwitchListTile( + selector: (context, s) => s.slideshowAnimatedZoomEffect, + onChanged: (v) => settings.slideshowAnimatedZoomEffect = v, + title: context.l10n.settingsSlideshowAnimatedZoomEffect, + ), SettingsSelectionListTile( values: ViewerTransition.values, getName: (context, v) => v.getName(context), diff --git a/lib/widgets/viewer/controller.dart b/lib/widgets/viewer/controller.dart index 21f3a006d..2353a75fa 100644 --- a/lib/widgets/viewer/controller.dart +++ b/lib/widgets/viewer/controller.dart @@ -1,20 +1,25 @@ import 'dart:async'; +import 'dart:math'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; import 'package:flutter/widgets.dart'; class ViewerController { final ValueNotifier entryNotifier = ValueNotifier(null); - final ScaleLevel initialScale; final ViewerTransition transition; final Duration? autopilotInterval; + final bool autopilotAnimatedZoom; final bool repeat; + late final ScaleLevel _initialScale; late final ValueNotifier _autopilotNotifier; Timer? _playTimer; final StreamController _streamController = StreamController.broadcast(); + final Map _autopilotAnimationControllers = {}; + ScaleLevel? _autopilotInitialScale; Stream get _events => _streamController.stream; @@ -26,13 +31,22 @@ class ViewerController { set autopilot(bool enabled) => _autopilotNotifier.value = enabled; + ScaleLevel get initialScale => _autopilotInitialScale ?? _initialScale; + + static final _autopilotScaleTweens = [ + Tween(begin: 1, end: 1.2), + Tween(begin: 1.2, end: 1), + ]; + ViewerController({ - this.initialScale = const ScaleLevel(ref: ScaleReference.contained), + ScaleLevel initialScale = const ScaleLevel(ref: ScaleReference.contained), this.transition = ViewerTransition.parallax, this.repeat = false, bool autopilot = false, this.autopilotInterval, + this.autopilotAnimatedZoom = false, }) { + _initialScale = initialScale; _autopilotNotifier = ValueNotifier(autopilot); _autopilotNotifier.addListener(_onAutopilotChange); _onAutopilotChange(); @@ -40,21 +54,53 @@ class ViewerController { void dispose() { _autopilotNotifier.removeListener(_onAutopilotChange); + _clearAutopilotAnimations(); _stopPlayTimer(); _streamController.close(); } - void _stopPlayTimer() { - _playTimer?.cancel(); - } - void _onAutopilotChange() { + _clearAutopilotAnimations(); _stopPlayTimer(); if (autopilot && autopilotInterval != null) { _playTimer = Timer.periodic(autopilotInterval!, (_) => _streamController.add(ViewerShowNextEvent())); _streamController.add(const ViewerOverlayToggleEvent(visible: false)); } } + + void _stopPlayTimer() => _playTimer?.cancel(); + + void _clearAutopilotAnimations() => _autopilotAnimationControllers.keys.toSet().forEach((v) => stopAutopilotAnimation(vsync: v)); + + void stopAutopilotAnimation({required TickerProvider vsync}) => _autopilotAnimationControllers.remove(vsync)?.dispose(); + + void startAutopilotAnimation({ + required TickerProvider vsync, + required void Function({required ScaleLevel scaleLevel}) onUpdate, + }) { + stopAutopilotAnimation(vsync: vsync); + if (!autopilot || !autopilotAnimatedZoom) return; + + final scaleLevelRef = _initialScale.ref; + final scaleFactorTween = _autopilotScaleTweens[Random().nextInt(_autopilotScaleTweens.length)]; + _autopilotInitialScale = ScaleLevel(ref: scaleLevelRef, factor: scaleFactorTween.begin!); + + final animationController = AnimationController( + duration: autopilotInterval, + vsync: vsync, + ); + animationController.addListener(() => onUpdate.call( + scaleLevel: ScaleLevel( + ref: scaleLevelRef, + factor: scaleFactorTween.evaluate(CurvedAnimation( + parent: animationController, + curve: Curves.linear, + )), + ), + )); + _autopilotAnimationControllers[vsync] = animationController; + Future.delayed(Durations.viewerHorizontalPageAnimation).then((_) => _autopilotAnimationControllers[vsync]?.forward()); + } } @immutable diff --git a/lib/widgets/viewer/entry_horizontal_pager.dart b/lib/widgets/viewer/entry_horizontal_pager.dart index c184a0a47..67752da83 100644 --- a/lib/widgets/viewer/entry_horizontal_pager.dart +++ b/lib/widgets/viewer/entry_horizontal_pager.dart @@ -90,7 +90,7 @@ class _MultiEntryScrollerState extends State with AutomaticK key: const Key('image_view'), mainEntry: mainEntry, pageEntry: pageEntry ?? mainEntry, - initialScale: viewerController.initialScale, + viewerController: viewerController, onDisposed: () => widget.onViewDisposed(mainEntry, pageEntry), ); } @@ -139,7 +139,7 @@ class _SingleEntryScrollerState extends State with Automati return EntryPageView( mainEntry: mainEntry, pageEntry: pageEntry ?? mainEntry, - initialScale: viewerController.initialScale, + viewerController: widget.viewerController, ); } diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 16150349e..5a06022f4 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -229,7 +229,7 @@ class _ViewerVerticalPageViewState extends State { if (animate) { pageController.animateToPage( target, - duration: const Duration(seconds: 1), + duration: Durations.viewerHorizontalPageAnimation, curve: Curves.easeInOutCubic, ); } else { diff --git a/lib/widgets/viewer/screen_saver_page.dart b/lib/widgets/viewer/screen_saver_page.dart index 036f9f179..7389cc793 100644 --- a/lib/widgets/viewer/screen_saver_page.dart +++ b/lib/widgets/viewer/screen_saver_page.dart @@ -43,6 +43,7 @@ class _ScreenSaverPageState extends State with WidgetsBindingOb repeat: true, autopilot: true, autopilotInterval: settings.screenSaverInterval.getDuration(), + autopilotAnimatedZoom: settings.screenSaverAnimatedZoomEffect, ); source.stateNotifier.addListener(_onSourceStateChanged); _initSlideshowCollection(); diff --git a/lib/widgets/viewer/slideshow_page.dart b/lib/widgets/viewer/slideshow_page.dart index d62be7afd..2d23769a5 100644 --- a/lib/widgets/viewer/slideshow_page.dart +++ b/lib/widgets/viewer/slideshow_page.dart @@ -45,6 +45,7 @@ class _SlideshowPageState extends State { repeat: settings.slideshowRepeat, autopilot: true, autopilotInterval: settings.slideshowInterval.getDuration(), + autopilotAnimatedZoom: settings.slideshowAnimatedZoomEffect, ); _initSlideshowCollection(); } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 8b8f93b59..f767b8d67 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -16,6 +16,7 @@ import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; import 'package:aves/widgets/common/magnifier/scale/state.dart'; import 'package:aves/widgets/common/thumbnail/image.dart'; +import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; @@ -35,7 +36,7 @@ import 'package:tuple/tuple.dart'; class EntryPageView extends StatefulWidget { final AvesEntry mainEntry, pageEntry; - final ScaleLevel initialScale; + final ViewerController viewerController; final VoidCallback? onDisposed; static const decorationCheckSize = 20.0; @@ -44,7 +45,7 @@ class EntryPageView extends StatefulWidget { super.key, required this.mainEntry, required this.pageEntry, - required this.initialScale, + required this.viewerController, this.onDisposed, }); @@ -52,7 +53,7 @@ class EntryPageView extends StatefulWidget { State createState() => _EntryPageViewState(); } -class _EntryPageViewState extends State { +class _EntryPageViewState extends State with SingleTickerProviderStateMixin { late ValueNotifier _viewStateNotifier; late MagnifierController _magnifierController; final List _subscriptions = []; @@ -72,6 +73,8 @@ class _EntryPageViewState extends State { AvesEntry get entry => widget.pageEntry; + ViewerController get viewerController => widget.viewerController; + // use the high res photo as cover for the video part of a motion photo ImageProvider get videoCoverUriImage => mainEntry.isMotionPhoto ? mainEntry.uriImage : entry.uriImage; @@ -112,9 +115,16 @@ class _EntryPageViewState extends State { _videoCoverStream = videoCoverUriImage.resolve(ImageConfiguration.empty); _videoCoverStream!.addListener(_videoCoverStreamListener); } + viewerController.startAutopilotAnimation( + vsync: this, + onUpdate: ({required scaleLevel}) { + final scale = _magnifierController.scaleBoundaries.scaleForLevel(scaleLevel); + _magnifierController.update(scale: scale, source: ChangeSource.animation); + }); } void _unregisterWidget(EntryPageView oldWidget) { + viewerController.stopAutopilotAnimation(vsync: this); _videoCoverStream?.removeListener(_videoCoverStreamListener); _videoCoverStream = null; _videoCoverInfoNotifier.value = null; @@ -385,7 +395,7 @@ class _EntryPageViewState extends State { allowOriginalScaleBeyondRange: !isWallpaperMode, minScale: minScale, maxScale: maxScale, - initialScale: widget.initialScale, + initialScale: viewerController.initialScale, scaleStateCycle: scaleStateCycle, applyScale: applyScale, onTap: (c, s, a, p) => _onTap(alignment: a), diff --git a/untranslated.json b/untranslated.json index 9586fe36b..0c86e7864 100644 --- a/untranslated.json +++ b/untranslated.json @@ -9,6 +9,7 @@ "albumGroupType", "albumMimeTypeMixed", "settingsDisabled", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "wallpaperUseScrollEffect" @@ -24,6 +25,7 @@ "albumGroupType", "albumMimeTypeMixed", "settingsDisabled", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "wallpaperUseScrollEffect" @@ -54,6 +56,7 @@ "searchMetadataSectionTitle", "settingsDisabled", "settingsConfirmationAfterMoveToBinItems", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "viewerInfoLabelDescription", @@ -74,6 +77,7 @@ "albumGroupType", "albumMimeTypeMixed", "settingsDisabled", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "wallpaperUseScrollEffect" @@ -89,6 +93,7 @@ "albumGroupType", "albumMimeTypeMixed", "settingsDisabled", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "wallpaperUseScrollEffect" @@ -120,6 +125,7 @@ "settingsDisabled", "settingsConfirmationAfterMoveToBinItems", "settingsViewerGestureSideTapNext", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "viewerInfoLabelDescription", @@ -140,6 +146,7 @@ "albumGroupType", "albumMimeTypeMixed", "settingsDisabled", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "wallpaperUseScrollEffect" @@ -155,6 +162,7 @@ "albumGroupType", "albumMimeTypeMixed", "settingsDisabled", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "wallpaperUseScrollEffect" @@ -170,6 +178,7 @@ "albumGroupType", "albumMimeTypeMixed", "settingsDisabled", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "wallpaperUseScrollEffect" @@ -220,6 +229,7 @@ "settingsSlideshowRepeat", "settingsSlideshowShuffle", "settingsSlideshowFillScreen", + "settingsSlideshowAnimatedZoomEffect", "settingsSlideshowTransitionTile", "settingsSlideshowTransitionDialogTitle", "settingsSlideshowIntervalTile", @@ -245,6 +255,7 @@ "albumGroupType", "albumMimeTypeMixed", "settingsDisabled", + "settingsSlideshowAnimatedZoomEffect", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "wallpaperUseScrollEffect"