From 622a330d82f37a7737c4d4717cdd682b5f0940f2 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 15 Jun 2026 10:10:05 -0500 Subject: [PATCH] chore: slideshow transition improvement (#29079) * chore: better slideshow transition * chore: tune * simplify setup * better default * fix: correctly zoom alternatively * lint --- .../lib/domain/models/config/app_config.dart | 2 - .../models/config/slideshow_config.dart | 30 ++--- mobile/lib/domain/models/settings_key.dart | 1 - .../pages/drift_slideshow.page.dart | 126 +++++++++++++++--- .../slideshow_settings.dart | 9 -- 5 files changed, 117 insertions(+), 51 deletions(-) diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index bf0be518fd..30e828311b 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -142,7 +142,6 @@ class AppConfig { .cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo, .cleanupDefaultsInitialized => cleanup.defaultsInitialized, .shareFileType => share.fileType, - .slideshowTransition => slideshow.transition, .slideshowRepeat => slideshow.repeat, .slideshowDuration => slideshow.duration, .slideshowLook => slideshow.look, @@ -196,7 +195,6 @@ class AppConfig { .cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)), .cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)), .shareFileType => copyWith(share: share.copyWith(fileType: value as ShareAssetType)), - .slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)), .slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)), .slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)), .slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)), diff --git a/mobile/lib/domain/models/config/slideshow_config.dart b/mobile/lib/domain/models/config/slideshow_config.dart index 74c0ac9d38..6bcdaadc77 100644 --- a/mobile/lib/domain/models/config/slideshow_config.dart +++ b/mobile/lib/domain/models/config/slideshow_config.dart @@ -1,48 +1,38 @@ import 'package:immich_mobile/constants/enums.dart'; class SlideshowConfig { - final bool transition; final bool repeat; final int duration; final SlideshowLook look; final SlideshowDirection direction; const SlideshowConfig({ - this.transition = true, this.repeat = true, this.duration = 5, - this.look = SlideshowLook.contain, + this.look = SlideshowLook.blurredBackground, this.direction = SlideshowDirection.forward, }); - SlideshowConfig copyWith({ - bool? transition, - bool? repeat, - int? duration, - SlideshowLook? look, - SlideshowDirection? direction, - }) => SlideshowConfig( - transition: transition ?? this.transition, - repeat: repeat ?? this.repeat, - duration: duration ?? this.duration, - look: look ?? this.look, - direction: direction ?? this.direction, - ); + SlideshowConfig copyWith({bool? repeat, int? duration, SlideshowLook? look, SlideshowDirection? direction}) => + SlideshowConfig( + repeat: repeat ?? this.repeat, + duration: duration ?? this.duration, + look: look ?? this.look, + direction: direction ?? this.direction, + ); @override bool operator ==(Object other) => identical(this, other) || (other is SlideshowConfig && - other.transition == transition && other.repeat == repeat && other.duration == duration && other.look == look && other.direction == direction); @override - int get hashCode => Object.hash(transition, repeat, duration, look, direction); + int get hashCode => Object.hash(repeat, duration, look, direction); @override - String toString() => - 'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)'; + String toString() => 'SlideshowConfig(repeat: $repeat, duration: $duration, look: $look, direction: $direction)'; } diff --git a/mobile/lib/domain/models/settings_key.dart b/mobile/lib/domain/models/settings_key.dart index 5a9ba0c7d8..2933c301b0 100644 --- a/mobile/lib/domain/models/settings_key.dart +++ b/mobile/lib/domain/models/settings_key.dart @@ -70,7 +70,6 @@ enum SettingsKey { shareFileType(codec: _EnumCodec(ShareAssetType.values)), // Slideshow - slideshowTransition(), slideshowRepeat(), slideshowDuration(), slideshowLook(codec: _EnumCodec(SlideshowLook.values)), diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 3a5f95554c..4fae0709aa 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -33,7 +33,9 @@ class DriftSlideshowPage extends ConsumerStatefulWidget { ConsumerState createState() => _DriftSlideshowPageState(); } -class _DriftSlideshowPageState extends ConsumerState { +class _DriftSlideshowPageState extends ConsumerState with SingleTickerProviderStateMixin { + static const double _kenBurnsZoom = 0.1; + late SlideshowConfig _config; late final PageController _pageController; late final Stopwatch _stopwatch; @@ -43,6 +45,12 @@ class _DriftSlideshowPageState extends ConsumerState { bool _paused = false; bool _showAppBar = false; + late final AnimationController _crossfadeController; + late final Animation _crossfadeOpacity; + int? _crossfadeFromIndex; + int? _crossfadeToIndex; + int _zoomCycle = 0; + @override initState() { super.initState(); @@ -50,6 +58,8 @@ class _DriftSlideshowPageState extends ConsumerState { final asset = ref.read(assetViewerProvider).currentAsset; _index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0; _pageController = PageController(initialPage: _index); + _crossfadeController = AnimationController(vsync: this, duration: Durations.extralong2); + _crossfadeOpacity = Tween(begin: 1.0, end: 0.0).animate(_crossfadeController); _stopwatch = Stopwatch(); _createTimer(); _updateNextIndex(); @@ -64,6 +74,7 @@ class _DriftSlideshowPageState extends ConsumerState { _timer.cancel(); _stopwatch.stop(); _pageController.dispose(); + _crossfadeController.dispose(); unawaited(WakelockPlus.disable()); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); @@ -150,11 +161,64 @@ class _DriftSlideshowPageState extends ConsumerState { await widget.timeline.preloadAssets(_nextIndex); } - if (_config.direction == SlideshowDirection.shuffle || !_config.transition) { - _pageController.jumpToPage(_nextIndex); + _crossFadeToPage(_nextIndex); + } + + void _crossFadeToPage(int page) { + final previousIndex = _index; + _pageController.jumpToPage(page); + setState(() { + _crossfadeFromIndex = previousIndex; + _crossfadeToIndex = page; + }); + _crossfadeController.forward(from: 0.0).whenComplete(() { + if (mounted) { + setState(() { + _crossfadeFromIndex = null; + _crossfadeToIndex = null; + }); + } + }); + } + + Widget _getCrossfadeLayer(BuildContext context, int index, {required bool isIncoming}) { + final asset = widget.timeline.getAssetSafe(index); + + final Widget child; + if (isIncoming && asset?.isImage == true) { + child = _getPhotoView(context, index); } else { - unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn)); + final zoomOut = isIncoming ? _zoomCycle.isOdd : _zoomCycle.isEven; + final zoom = isIncoming ? (zoomOut ? 1.0 : 0.0) : (zoomOut ? 0.0 : 1.0); + child = _getCrossfadeChild(context, index, zoom); } + + return Stack( + fit: StackFit.expand, + children: [if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), child], + ); + } + + Widget _getCrossfadeChild(BuildContext context, int index, double zoom) { + final asset = widget.timeline.getAssetSafe(index); + + if (asset == null) { + return const SizedBox.shrink(); + } + + final scale = _config.look == SlideshowLook.cover + ? PhotoViewComputedScale.covered + : PhotoViewComputedScale.contained; + + return PhotoView( + imageProvider: getFullImageProvider(asset, size: context.sizeData), + index: index, + disableScaleGestures: true, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + initialScale: scale * (1.0 + zoom * _kenBurnsZoom), + controller: PhotoViewController(), + ); } void _createTimer() { @@ -172,6 +236,7 @@ class _DriftSlideshowPageState extends ConsumerState { setState(() { _index = page; + _zoomCycle++; if (!asset.isImage) { _paused = false; @@ -268,7 +333,7 @@ class _DriftSlideshowPageState extends ConsumerState { final imageProvider = getFullImageProvider(asset, size: context.sizeData); if (asset.isImage) { - final zoomOut = index % 2 == 1; + final zoomOut = _zoomCycle.isOdd; final elapsed = _stopwatch.elapsedMilliseconds; final duration = _config.duration * 1000; final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble(); @@ -289,7 +354,7 @@ class _DriftSlideshowPageState extends ConsumerState { disableScaleGestures: true, gaplessPlayback: true, filterQuality: FilterQuality.high, - initialScale: scale * (1.0 + value / 10.0), + initialScale: scale * (1.0 + value * _kenBurnsZoom), controller: PhotoViewController(), onTapUp: (_, _, _) => _onTapUp(), ), @@ -356,20 +421,43 @@ class _DriftSlideshowPageState extends ConsumerState { extendBody: true, extendBodyBehindAppBar: true, backgroundColor: Colors.black, - body: PhotoViewGestureDetectorScope( - axis: Axis.horizontal, - child: PageView.builder( - controller: _pageController, - physics: const FastClampingScrollPhysics(), - itemCount: widget.timeline.totalAssets, - onPageChanged: _pageChanged, - itemBuilder: (context, index) => Stack( - children: [ - if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), - _getPhotoView(context, index), - ], + body: Stack( + children: [ + PhotoViewGestureDetectorScope( + axis: Axis.horizontal, + child: PageView.builder( + controller: _pageController, + physics: const FastClampingScrollPhysics(), + itemCount: widget.timeline.totalAssets, + onPageChanged: _pageChanged, + itemBuilder: (context, index) => Stack( + children: [ + if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), + _getPhotoView(context, index), + ], + ), + ), ), - ), + if (_crossfadeFromIndex != null && _crossfadeToIndex != null) + Positioned.fill( + child: IgnorePointer( + child: Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: Colors.black), + FadeTransition( + opacity: _crossfadeController, + child: _getCrossfadeLayer(context, _crossfadeToIndex!, isIncoming: true), + ), + FadeTransition( + opacity: _crossfadeOpacity, + child: _getCrossfadeLayer(context, _crossfadeFromIndex!, isIncoming: false), + ), + ], + ), + ), + ), + ], ), ); } diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart index 5f93d429b0..af361943ec 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart @@ -16,15 +16,11 @@ class SlideshowSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final slideshow = ref.read(appConfigProvider).slideshow; - final useTransition = useState(slideshow.transition); final useRepeat = useState(slideshow.repeat); final useDuration = useState(slideshow.duration); final useLook = useState(slideshow.look); final useDirection = useState(slideshow.direction); - useValueChanged(useTransition.value, (_, __) { - ref.read(settingsProvider).write(.slideshowTransition, useTransition.value); - }); useValueChanged(useRepeat.value, (_, __) { ref.read(settingsProvider).write(.slideshowRepeat, useRepeat.value); }); @@ -45,11 +41,6 @@ class SlideshowSettings extends HookConsumerWidget { title: 'slideshow'.t(context: context), icon: Icons.slideshow_outlined, ), - SettingsSwitchListTile( - valueNotifier: useTransition, - title: "show_slideshow_transition".t(context: context), - enabled: useDirection.value != SlideshowDirection.shuffle, - ), SettingsSwitchListTile( valueNotifier: useRepeat, title: "slideshow_repeat".t(context: context),