#290 slideshow: animated zoom effect

This commit is contained in:
Thibault Deckers 2022-10-04 19:10:15 +02:00
parent 1fa68f082f
commit 6df9456372
17 changed files with 115 additions and 18 deletions

View file

@ -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

View file

@ -715,6 +715,7 @@
"settingsSlideshowRepeat": "Repeat",
"settingsSlideshowShuffle": "Shuffle",
"settingsSlideshowFillScreen": "Fill screen",
"settingsSlideshowAnimatedZoomEffect": "Animated zoom effect",
"settingsSlideshowTransitionTile": "Transition",
"settingsSlideshowTransitionDialogTitle": "Transition",
"settingsSlideshowIntervalTile": "Interval",

View file

@ -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",

View file

@ -534,6 +534,7 @@
"settingsSlideshowRepeat": "반복",
"settingsSlideshowShuffle": "순서섞기",
"settingsSlideshowFillScreen": "화면 채우기",
"settingsSlideshowAnimatedZoomEffect": "애니메이션 확대/축소 효과",
"settingsSlideshowTransitionTile": "전환 효과",
"settingsSlideshowTransitionDialogTitle": "전환 효과",
"settingsSlideshowIntervalTile": "교체 주기",

View file

@ -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;

View file

@ -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 {

View file

@ -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);

View file

@ -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);

View file

@ -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<ViewerTransition>(
values: ViewerTransition.values,
getName: (context, v) => v.getName(context),

View file

@ -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<ViewerTransition>(
values: ViewerTransition.values,
getName: (context, v) => v.getName(context),

View file

@ -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<AvesEntry?> 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<bool> _autopilotNotifier;
Timer? _playTimer;
final StreamController _streamController = StreamController.broadcast();
final Map<TickerProvider, AnimationController> _autopilotAnimationControllers = {};
ScaleLevel? _autopilotInitialScale;
Stream<dynamic> 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<double>(begin: 1, end: 1.2),
Tween<double>(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

View file

@ -90,7 +90,7 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> 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<SingleEntryScroller> with Automati
return EntryPageView(
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
initialScale: viewerController.initialScale,
viewerController: widget.viewerController,
);
}

View file

@ -229,7 +229,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
if (animate) {
pageController.animateToPage(
target,
duration: const Duration(seconds: 1),
duration: Durations.viewerHorizontalPageAnimation,
curve: Curves.easeInOutCubic,
);
} else {

View file

@ -43,6 +43,7 @@ class _ScreenSaverPageState extends State<ScreenSaverPage> with WidgetsBindingOb
repeat: true,
autopilot: true,
autopilotInterval: settings.screenSaverInterval.getDuration(),
autopilotAnimatedZoom: settings.screenSaverAnimatedZoomEffect,
);
source.stateNotifier.addListener(_onSourceStateChanged);
_initSlideshowCollection();

View file

@ -45,6 +45,7 @@ class _SlideshowPageState extends State<SlideshowPage> {
repeat: settings.slideshowRepeat,
autopilot: true,
autopilotInterval: settings.slideshowInterval.getDuration(),
autopilotAnimatedZoom: settings.slideshowAnimatedZoomEffect,
);
_initSlideshowCollection();
}

View file

@ -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<EntryPageView> createState() => _EntryPageViewState();
}
class _EntryPageViewState extends State<EntryPageView> {
class _EntryPageViewState extends State<EntryPageView> with SingleTickerProviderStateMixin {
late ValueNotifier<ViewState> _viewStateNotifier;
late MagnifierController _magnifierController;
final List<StreamSubscription> _subscriptions = [];
@ -72,6 +73,8 @@ class _EntryPageViewState extends State<EntryPageView> {
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<EntryPageView> {
_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<EntryPageView> {
allowOriginalScaleBeyondRange: !isWallpaperMode,
minScale: minScale,
maxScale: maxScale,
initialScale: widget.initialScale,
initialScale: viewerController.initialScale,
scaleStateCycle: scaleStateCycle,
applyScale: applyScale,
onTap: (c, s, a, p) => _onTap(alignment: a),

View file

@ -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"