From 31943fbe07bdcf42598f3bcec7303275d7aa5048 Mon Sep 17 00:00:00 2001 From: Arthur <48799751+arth3mis@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:14:28 +0200 Subject: [PATCH 1/2] fix(mobile): make shared link download toggle depend on metadata toggle (#31264) Co-authored-by: Arthur --- .../lib/pages/library/shared_link/shared_link_edit.page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart index 6c905714281eff..bc7087c89806c5 100644 --- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart @@ -157,8 +157,8 @@ class SharedLinkEditPage extends HookConsumerWidget { Widget buildAllowDownloadButton() { return SwitchListTile.adaptive( - value: allowDownload.value, - onChanged: (value) => allowDownload.value = value, + value: allowDownload.value && showMetadata.value, + onChanged: showMetadata.value ? (value) => allowDownload.value = value : null, dense: true, title: Text( context.t.allow_public_user_to_download, From a6d43828f6cc23e7b0c58f5ece8fa44335889bfb Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 4 Sep 2026 07:48:49 -0700 Subject: [PATCH 2/2] fix(mobile): rewrite slideshow controller system (#30771) * fix(mobile): rewrite slideshow controller system * Normalize playback status changes * Improve preloading assets * Added controller and widget tests * Minor fixes from PR comments * More minor fixes * Move slideshow test after rename --- .../presentation/pages/slideshow.page.dart | 679 ++++++------------ .../asset_viewer/video_viewer.widget.dart | 6 +- .../slideshow/slideshow_controller.dart | 205 ++++++ .../slideshow_progress_bar.widget.dart | 34 + .../slideshow/slideshow_slide.widget.dart | 154 ++++ .../pages/slideshow_page_test.dart | 380 ++++++++++ .../slideshow/slideshow_controller_test.dart | 202 ++++++ mobile/test/unit/mocks.dart | 4 + .../presentation/presentation_context.dart | 24 +- 9 files changed, 1223 insertions(+), 465 deletions(-) create mode 100644 mobile/lib/presentation/widgets/slideshow/slideshow_controller.dart create mode 100644 mobile/lib/presentation/widgets/slideshow/slideshow_progress_bar.widget.dart create mode 100644 mobile/lib/presentation/widgets/slideshow/slideshow_slide.widget.dart create mode 100644 mobile/test/presentation/pages/slideshow_page_test.dart create mode 100644 mobile/test/presentation/widgets/slideshow/slideshow_controller_test.dart diff --git a/mobile/lib/presentation/pages/slideshow.page.dart b/mobile/lib/presentation/pages/slideshow.page.dart index a7df9903319177..b808efb8cd5a33 100644 --- a/mobile/lib/presentation/pages/slideshow.page.dart +++ b/mobile/lib/presentation/pages/slideshow.page.dart @@ -1,6 +1,4 @@ import 'dart:async'; -import 'dart:math'; -import 'dart:ui'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; @@ -14,8 +12,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/settings.page.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; -import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_controller.dart'; +import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_progress_bar.widget.dart'; +import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_slide.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; @@ -35,38 +34,74 @@ class SlideshowPage extends ConsumerStatefulWidget { ConsumerState createState() => _SlideshowPageState(); } -class _SlideshowPageState extends ConsumerState with SingleTickerProviderStateMixin { - static const double _kenBurnsZoom = 0.1; - - late SlideshowConfig _config; +class _SlideshowPageState extends ConsumerState + with TickerProviderStateMixin + implements SlideshowDelegate { + late final SlideshowController _slideshow; late final PageController _pageController; - late final Stopwatch _stopwatch; - late Timer _timer; - late int _index; - late int _nextIndex; - bool _paused = false; - bool _showAppBar = false; - late final AnimationController _crossfadeController; - late final Animation _crossfadeOpacity; - int? _crossfadeFromIndex; - int? _crossfadeToIndex; - int _zoomCycle = 0; + late final AnimationController _fade; + late final Animation _fadeOut; + + /// While non-null, a frozen copy of this slide is fading out over the live page. + int? _fadingSlideIndex; + + bool _showAppBar = false; bool _disableAnimations = false; + SlideshowConfig get _config => ref.read(appConfigProvider).slideshow; + + BaseAsset? _assetAt(int index) => widget.timeline.getAssetSafe(index); + + BaseAsset? _videoAt(int index) { + final asset = _assetAt(index); + + if (asset == null || asset.isImage) { + return null; + } + + return asset; + } + + VideoPlayerState _videoState(BaseAsset asset) => ref.read(videoPlayerProvider(asset.id)); + + VideoPlayerNotifier? _videoNotifier(int index) { + final video = _videoAt(index); + + if (video == null) { + return null; + } + + return ref.read(videoPlayerProvider(video.id).notifier); + } + @override void initState() { super.initState(); - _config = ref.read(appConfigProvider.select((s) => s.slideshow)); + 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(); - ref.listenManual(appConfigProvider.select((s) => s.slideshow), _onConfigChanged); + final assetIndex = asset != null ? widget.timeline.getIndex(asset.heroTag) : null; + final initialIndex = assetIndex ?? 0; + + _pageController = PageController(initialPage: initialIndex); + _fade = AnimationController(vsync: this, duration: Durations.extralong2); + _fadeOut = _fade.drive(Tween(begin: 1.0, end: 0.0)); + + _slideshow = SlideshowController( + vsync: this, + slideDuration: Duration(seconds: _config.duration), + initialIndex: initialIndex, + delegate: this, + ); + + ref.listenManual(appConfigProvider.select((s) => s.slideshow), (previous, next) { + _slideshow.slideDuration = Duration(seconds: next.duration); + + // A new direction or repeat change can cause a different next slide + _slideshow.recalculateNextIndex(); + }); + + _slideshow.goToNextSlide(); unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive)); unawaited(WakelockPlus.enable()); @@ -80,194 +115,112 @@ class _SlideshowPageState extends ConsumerState with SingleTicker @override void dispose() { - _timer.cancel(); - _stopwatch.stop(); + _slideshow.dispose(); + _fade.dispose(); _pageController.dispose(); - _crossfadeController.dispose(); + unawaited(WakelockPlus.disable()); unawaited(restoreEdgeToEdge()); super.dispose(); } - void _play() { - final asset = widget.timeline.getAssetSafe(_index)!; - - if (asset.isImage) { - _createTimer(); - } else if (ref.read(videoPlayerProvider(asset.id)).status == VideoPlaybackStatus.paused) { - unawaited(ref.read(videoPlayerProvider(asset.id).notifier).play()); - } else { - unawaited(_nextPage()); - } - - _updateNextIndex(); - - setState(() { - _paused = false; - }); - } - - void _pause() { - _timer.cancel(); - _stopwatch.stop(); - - final asset = widget.timeline.getAssetSafe(_index)!; - - if (!asset.isImage) { - unawaited(ref.read(videoPlayerProvider(asset.id).notifier).pause()); - } - - setState(() { - _paused = true; - }); - } - - void _onConfigChanged(SlideshowConfig? previous, SlideshowConfig next) { - if (_config == next) { - return; - } - - final durationChanged = _config.duration != next.duration; - _config = next; - _updateNextIndex(); - - final asset = widget.timeline.getAssetSafe(_index); - if (durationChanged && !_paused && asset?.isImage == true) { - _timer.cancel(); - _createTimer(); + @override + int? nextIndexAfter(int index) { + if (widget.timeline.totalAssets == 0) { + return null; } - setState(() {}); - } - - void _updateNextIndex() { - _nextIndex = switch (_config.direction) { - SlideshowDirection.forward => _index + 1, - SlideshowDirection.backward => _index - 1, + var next = switch (_config.direction) { + SlideshowDirection.forward => index + 1, + SlideshowDirection.backward => index - 1, SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!, }; - if (!widget.timeline.hasRange(_nextIndex, 1)) { - unawaited(widget.timeline.preloadAssets(_nextIndex)); - } - } - - Future _nextPage() async { - if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) { - if (_config.repeat) { - final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1; - await widget.timeline.preloadAssets(wrapped); - _pageController.jumpToPage(wrapped); - } else { - setState(() { - _paused = true; - }); + if (next < 0 || next >= widget.timeline.totalAssets) { + // Out of bounds + if (!_config.repeat) { + // Don't wrap. End of slideshow + return null; } - return; - } - if (!widget.timeline.hasRange(_nextIndex, 1)) { - await widget.timeline.preloadAssets(_nextIndex); + // Do wrap + next = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1; } - _crossFadeToPage(_nextIndex); - } - - void _crossFadeToPage(int page) { - if (_disableAnimations) { - _pageController.jumpToPage(page); - return; + if (!widget.timeline.hasRange(next, 1)) { + // Async preload this index. We don't want to wait on it, just get it started so the asset is more likely to be ready in time + unawaited(widget.timeline.preloadAssets(next)); } - final previousIndex = _index; - _pageController.jumpToPage(page); - setState(() { - _crossfadeFromIndex = previousIndex; - _crossfadeToIndex = page; - }); - unawaited( - _crossfadeController.forward(from: 0.0).whenComplete(() { - if (mounted) { - setState(() { - _crossfadeFromIndex = null; - _crossfadeToIndex = null; - }); - } - }), - ); + return next; } - Widget _getCrossfadeLayer(BuildContext context, int index, {required bool isIncoming}) { - final asset = widget.timeline.getAssetSafe(index); + @override + Duration? videoProgressOf(int index) { + final video = _videoAt(index); - final Widget child; - if (isIncoming && asset?.isImage == true) { - child = _getPhotoView(context, index); - } else { - 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); + if (video == null) { + return null; } - return Stack( - fit: StackFit.expand, - children: [if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), child], - ); + return _videoState(video).position; } - Widget _getCrossfadeChild(BuildContext context, int index, double zoom) { - final asset = widget.timeline.getAssetSafe(index); + @override + bool isVideoCompleted(int index) { + final video = _videoAt(index); - if (asset == null) { - return const SizedBox.shrink(); - } + return video != null && _videoState(video).status == VideoPlaybackStatus.completed; + } - final scale = _config.look == SlideshowLook.cover - ? PhotoViewComputedScale.covered - : PhotoViewComputedScale.contained; + @override + void onPlaybackChanged(int index, bool playing) { + final video = _videoNotifier(index); - 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(), - ); + unawaited(playing ? video?.play() : video?.pause()); } - void _createTimer() { - _timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () { - _stopwatch.stop(); - _stopwatch.reset(); - unawaited(_nextPage()); - }); + @override + void onShowSlide(int index, int prevIndex) { + unawaited(() async { + if (index == prevIndex) { + // Showing the same slide again. Don't need to animate + if (isVideoCompleted(index)) { + unawaited(_videoNotifier(index)?.restart()); + } - _stopwatch.start(); - } + _slideshow.didCompleteShowSlide(index); - void _pageChanged(int page) { - final asset = widget.timeline.getAssetSafe(page)!; + return; + } - setState(() { - _index = page; - _zoomCycle++; + if (!widget.timeline.hasRange(index, 1)) { + // If it wasn't already loaded by [nextIndexAfter], make sure we have the new asset synchronously + await widget.timeline.preloadAssets(index); + } - if (!asset.isImage) { - _paused = false; + if (!mounted || _slideshow.currentIndex != prevIndex) { + // User triggered another slide while we waited on preload + return; } - }); - _timer.cancel(); - _stopwatch.stop(); - _stopwatch.reset(); + _pageController.jumpToPage(index); - if (!_paused && asset.isImage) { - _createTimer(); - } + // didCompleteShowSlide will be called by onPageChanged, so we don't need to call it in any of these branches + if (_disableAnimations) { + return; + } + + setState(() => _fadingSlideIndex = prevIndex); - _updateNextIndex(); + unawaited( + _fade.forward(from: 0.0).whenComplete(() { + if (mounted) { + setState(() => _fadingSlideIndex = null); + } + }), + ); + }()); } Future _onTapUp() async { @@ -280,314 +233,116 @@ class _SlideshowPageState extends ConsumerState with SingleTicker }); } - Widget _getProgressBar(BuildContext context) { - final asset = widget.timeline.getAssetSafe(_index); - - if (asset == null) { - return Container(); + /// Zoom for the current Ken Burns cycle, moving from 0 -> 1, or 1 -> 0 + Animation get _zoom { + if (_disableAnimations) { + return const AlwaysStoppedAnimation(0.0); } - if (asset.isImage) { - return _SlideshowProgressBar( - key: Key(_index.toString()), - durationMs: _config.duration * 1000, - elapsedMs: _stopwatch.elapsedMilliseconds, - paused: _paused, - color: context.colorScheme.primary, - ); - } else { - return _VideoProgressBar(asset: asset); - } + return _slideshow.progress.drive( + _slideshow.shouldZoomOut ? Tween(begin: 1.0, end: 0.0) : Tween(begin: 0.0, end: 1.0), + ); } - Widget _getBlur(BuildContext context, int index) { - final asset = widget.timeline.getAssetSafe(index); - + Widget _buildSlide(int index, SlideshowLook look) { + final asset = _assetAt(index); if (asset == null) { - return Container(); + return const Center(child: ImmichLoadingIndicator()); } - return ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: getFullImageProvider(asset, size: Size(context.width, context.height)), - fit: BoxFit.cover, - ), - ), - child: Container(color: Colors.black.withValues(alpha: 0.2)), - ), + return SlideshowSlide( + asset: asset, + index: index, + look: look, + zoom: _zoom, + isCurrent: _slideshow.currentIndex == index, + onTapUp: _onTapUp, + onCompleted: _slideshow.didCompleteVideo, ); } - Widget _getPhotoView(BuildContext context, int index) { - final asset = widget.timeline.getAssetSafe(index); - + /// The outgoing slide, frozen in its last position + Widget _buildFadingSlide(int index, SlideshowLook look) { + final asset = _assetAt(index); if (asset == null) { - return const Center(child: ImmichLoadingIndicator()); + return const SizedBox.shrink(); } - final scale = _config.look == SlideshowLook.cover - ? PhotoViewComputedScale.covered - : PhotoViewComputedScale.contained; - final isCurrent = _index == index; - final imageProvider = getFullImageProvider(asset, size: context.sizeData); - - if (asset.isImage) { - PhotoView buildPhotoView(PhotoViewComputedScale initialScale) => PhotoView( - imageProvider: imageProvider, - index: index, - disableScaleGestures: true, - gaplessPlayback: true, - filterQuality: FilterQuality.high, - initialScale: initialScale, - controller: PhotoViewController(), - onTapUp: (_, _, _) => _onTapUp(), - ); - - if (_disableAnimations) { - return buildPhotoView(scale); - } - - final zoomOut = _zoomCycle.isOdd; - final elapsed = _stopwatch.elapsedMilliseconds; - final duration = _config.duration * 1000; - final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble(); - - return TweenAnimationBuilder( - tween: Tween( - begin: progress, - end: _paused - ? progress - : zoomOut - ? 0.0 - : 1.0, - ), - duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), - builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)), - ); - } else { - return _VideoChild( - asset: asset, - isCurrent: isCurrent, - scale: scale, - imageProvider: imageProvider, - onTapUp: _onTapUp, - onCompleted: _nextPage, - ); - } + return SlideshowSlide.frozen(asset: asset, index: index, look: look, zoom: _slideshow.shouldZoomOut ? 1.0 : 0.0); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: PreferredSize( - preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5), - child: IgnorePointer( - ignoring: !_showAppBar, - child: AnimatedOpacity( - opacity: _showAppBar ? 1.0 : 0.0, - duration: Durations.short2, - child: Column( - children: [ - AppBar( - backgroundColor: context.scaffoldBackgroundColor, - title: Text(context.t.slideshow), - actions: [ - IconButton( - onPressed: _paused ? _play : _pause, - icon: Icon(_paused ? Icons.play_arrow : Icons.pause), - ), - IconButton( - onPressed: () { - _pause(); - unawaited(context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer))); - }, - icon: const Icon(Icons.settings), + final config = ref.watch(appConfigProvider.select((s) => s.slideshow)); + + return ListenableBuilder( + listenable: _slideshow, + builder: (context, _) { + final currentAsset = _assetAt(_slideshow.currentIndex); + final progressBar = currentAsset != null + ? SlideshowProgressBar(asset: currentAsset, progress: _slideshow.progress) + : const SizedBox.shrink(); + + return Scaffold( + appBar: PreferredSize( + preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5), + child: IgnorePointer( + ignoring: !_showAppBar, + child: AnimatedOpacity( + opacity: _showAppBar ? 1.0 : 0.0, + duration: Durations.short2, + child: Column( + children: [ + AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text(context.t.slideshow), + actions: [ + IconButton( + onPressed: _slideshow.paused ? _slideshow.resume : _slideshow.pause, + icon: Icon(_slideshow.paused ? Icons.play_arrow : Icons.pause), + ), + IconButton( + onPressed: () { + if (!_slideshow.paused) { + _slideshow.pause(); + } + unawaited(context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer))); + }, + icon: const Icon(Icons.settings), + ), + ], ), + progressBar, ], ), - _getProgressBar(context), - ], - ), - ), - ), - ), - extendBody: true, - extendBodyBehindAppBar: true, - backgroundColor: Colors.black, - 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), - ), - ], + extendBody: true, + extendBodyBehindAppBar: true, + backgroundColor: Colors.black, + body: Stack( + children: [ + PhotoViewGestureDetectorScope( + axis: Axis.horizontal, + child: PageView.builder( + controller: _pageController, + physics: const FastClampingScrollPhysics(), + itemCount: widget.timeline.totalAssets, + onPageChanged: _slideshow.didCompleteShowSlide, + itemBuilder: (context, index) => _buildSlide(index, config.look), ), ), - ), - ], - ), - ); - } -} - -class _VideoChild extends ConsumerWidget { - final BaseAsset asset; - final bool isCurrent; - final PhotoViewComputedScale scale; - final ImageProvider imageProvider; - final VoidCallback onTapUp; - final VoidCallback onCompleted; - - const _VideoChild({ - required this.asset, - required this.isCurrent, - required this.scale, - required this.imageProvider, - required this.onTapUp, - required this.onCompleted, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) { - if (status == VideoPlaybackStatus.completed) { - if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) { - onCompleted(); - } - } else if (status == VideoPlaybackStatus.playing) { - unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); - } - }); - - return PhotoView.customChild( - onTapUp: (_, _, _) => onTapUp(), - disableScaleGestures: true, - filterQuality: FilterQuality.high, - initialScale: scale, - child: NativeVideoViewer( - asset: asset, - isCurrent: isCurrent, - image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), - ), - ); - } -} - -class _VideoProgressBar extends ConsumerWidget { - final BaseAsset asset; - - const _VideoProgressBar({required this.asset}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position)); - - return LinearProgressIndicator( - color: context.colorScheme.primary, - borderRadius: BorderRadius.zero, - minHeight: 5, - value: position.inMilliseconds / asset.duration.inMilliseconds, - ); - } -} - -/// Progress bar for image slides, driven by an explicit [AnimationController]. -/// -/// [TweenAnimationBuilder] creates its controller internally with the default -/// [AnimationBehavior.normal], which makes it run ~20x too fast while the system -/// "reduce motion" setting is on (flutter/flutter#164287). This owns its -/// controller so it can use [AnimationBehavior.preserve] and animate at the real -/// slide duration regardless of that setting. -class _SlideshowProgressBar extends StatefulWidget { - final int durationMs; - final int elapsedMs; - final bool paused; - final Color color; - - const _SlideshowProgressBar({ - super.key, - required this.durationMs, - required this.elapsedMs, - required this.paused, - required this.color, - }); - - @override - State<_SlideshowProgressBar> createState() => _SlideshowProgressBarState(); -} - -class _SlideshowProgressBarState extends State<_SlideshowProgressBar> with SingleTickerProviderStateMixin { - late final AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: Duration(milliseconds: widget.durationMs), - animationBehavior: AnimationBehavior.preserve, - )..value = (widget.elapsedMs / widget.durationMs).clamp(0.0, 1.0); - if (!widget.paused) { - unawaited(_controller.forward()); - } - } - - @override - void didUpdateWidget(_SlideshowProgressBar oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.durationMs != oldWidget.durationMs) { - _controller.duration = Duration(milliseconds: widget.durationMs); - } - if (widget.paused != oldWidget.paused) { - widget.paused ? _controller.stop() : _controller.forward(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, _) => LinearProgressIndicator( - color: widget.color, - borderRadius: BorderRadius.zero, - minHeight: 5, - value: _controller.value, - ), + if (_fadingSlideIndex != null) + Positioned.fill( + child: IgnorePointer( + child: FadeTransition(opacity: _fadeOut, child: _buildFadingSlide(_fadingSlideIndex!, config.look)), + ), + ), + ], + ), + ); + }, ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index f0e01d8f0a51cb..903f53c15379f9 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -25,6 +25,9 @@ class NativeVideoViewer extends ConsumerStatefulWidget { final bool showControls; final Widget image; + /// Overrides the user's configured loop video setting + final bool? loopOverride; + const NativeVideoViewer({ super.key, required this.asset, @@ -32,6 +35,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget { required this.image, this.isCurrent = false, this.showControls = true, + this.loopOverride, }); @override @@ -278,7 +282,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg } // Grab refs to prevent reading after dispose - final loopVideo = ref.read(appConfigProvider).viewer.loopVideo; + final loopVideo = widget.loopOverride ?? ref.read(appConfigProvider).viewer.loopVideo; final localNotifier = _notifier; await localNotifier.load(source); diff --git a/mobile/lib/presentation/widgets/slideshow/slideshow_controller.dart b/mobile/lib/presentation/widgets/slideshow/slideshow_controller.dart new file mode 100644 index 00000000000000..0044b67fca8067 --- /dev/null +++ b/mobile/lib/presentation/widgets/slideshow/slideshow_controller.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// The shared instance behavior between individual slideshow components +abstract interface class SlideshowDelegate { + /// Provides the index of the slide to display after the slide at [index]. A returned `null` index indicates the slideshow should terminate "next" + int? nextIndexAfter(int index); + + /// Provides the playback position of the video slide at [index], or null if the slide is not a video + Duration? videoProgressOf(int index); + + /// Provides the completion state of the video slide at [index], or false if the slide is not a video + bool isVideoCompleted(int index); + + /// Called when the slideshow starts or stops playing + void onPlaybackChanged(int index, bool playing); + + /// Called to indicate the slide corresponding to [index] should be displayed, transitioning from [prevIndex] + /// + /// **NOTE:** The receiver MUST call [SlideshowController.didCompleteShowSlide] when the slide became "ready" + void onShowSlide(int index, int prevIndex); +} + +/// Manages Flutter slideshow rendering +/// +/// After constructing, call [goToNextSlide] to begin the slideshow +class SlideshowController extends ChangeNotifier { + final SlideshowDelegate delegate; + + late final AnimationController _animationController; + + /// The index of the currently displayed slide + int _currentIndex; + + /// The index of the expected next displayed slide + int? _nextIndex; + + bool _paused = false; + bool _shouldZoomOut = true; + + /// The last recorded video playback position + Duration _lastVideoPosition = Duration.zero; + + SlideshowController({ + required TickerProvider vsync, + required Duration slideDuration, + required int initialIndex, + required this.delegate, + }) : _currentIndex = initialIndex { + _animationController = + AnimationController( + vsync: vsync, + duration: slideDuration, + // This `AnimationController` serves as the actual slideshow timer, so we must ignore reduce motion + animationBehavior: AnimationBehavior.preserve, + )..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _onAnimationElapsed(); + } + }); + + // We want to go to the first slide + _nextIndex = initialIndex; + } + + /// The slide currently on screen + int get currentIndex => _currentIndex; + + /// The slide to be displayed next. Null if the slideshow will end after this slide + int? get nextIndex => _nextIndex; + + /// True when the user paused or the slideshow has completed + bool get paused => _paused; + + /// Ken Burns zoom animation direction + /// + /// Each slide transitions from zooming in/out to out/in + bool get shouldZoomOut => _shouldZoomOut; + + /// The slideshow clock/progress indicator. Its value, [0.0, 1.0] represents the progress through the duration of the current slide + Animation get progress => _animationController; + + /// The display duration of a single slide. Setting a new duration mid-animation will continue from the current percentage completion at the new pace + set slideDuration(Duration duration) { + _animationController.duration = duration; + + if (!_paused && _animationController.isAnimating) { + unawaited(_animationController.forward()); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + /// Stops the slideshow at its current position + void pause() { + _paused = true; + _animationController.stop(); + + delegate.onPlaybackChanged(_currentIndex, false); + + notifyListeners(); + } + + /// Resume the slideshow from its current position + void resume() { + _paused = false; + notifyListeners(); + + if (delegate.isVideoCompleted(_currentIndex)) { + goToNextSlide(); + return; + } + + if (_animationController.isCompleted) { + // If slide hit the end, restart it from 0 + _animationController.value = 0.0; + } + + unawaited(_animationController.forward()); + + delegate.onPlaybackChanged(_currentIndex, true); + } + + /// Immediately transitions to the previously determined next slide + /// + /// If there is no [_nextIndex], pauses the slideshow + void goToNextSlide() { + _animationController.stop(); + + final targetIndex = _nextIndex; + + if (targetIndex == null) { + _paused = true; + + notifyListeners(); + return; + } + + delegate.onShowSlide(targetIndex, _currentIndex); + } + + /// Indicates the slide at [index] is now displayed + void didCompleteShowSlide(int index) { + _currentIndex = index; + _nextIndex = delegate.nextIndexAfter(index); + + _shouldZoomOut = !_shouldZoomOut; + + if (delegate.videoProgressOf(index) != null) { + // Visiting a video immediately starts playback (as part of NativeVideoPlayer) + // We do not want to unpause outside of videos + _paused = false; + } + + _startSlideTimer(); + + notifyListeners(); + } + + /// Indicates the currently displayed video slide finished playback + void didCompleteVideo() { + if (!_paused) { + goToNextSlide(); + } + } + + /// Recalculates the next slide index + void recalculateNextIndex() { + _nextIndex = delegate.nextIndexAfter(_currentIndex); + + notifyListeners(); + } + + /// Begin the timer for the current slide + void _startSlideTimer() { + _lastVideoPosition = delegate.videoProgressOf(_currentIndex) ?? Duration.zero; + + if (_paused) { + _animationController.value = 0.0; + } else { + unawaited(_animationController.forward(from: 0.0)); + } + } + + void _onAnimationElapsed() { + final videoPosition = delegate.videoProgressOf(_currentIndex); + + if (videoPosition != null && videoPosition != _lastVideoPosition) { + // Video progress has been made and thus is not stalled + _lastVideoPosition = videoPosition; + + // Restart the slide timer in case the video stalls in the future + unawaited(_animationController.forward(from: 0.0)); + + return; + } + + goToNextSlide(); + } +} diff --git a/mobile/lib/presentation/widgets/slideshow/slideshow_progress_bar.widget.dart b/mobile/lib/presentation/widgets/slideshow/slideshow_progress_bar.widget.dart new file mode 100644 index 00000000000000..f37deb4718b430 --- /dev/null +++ b/mobile/lib/presentation/widgets/slideshow/slideshow_progress_bar.widget.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; + +/// Bar indicating current progress through a given slide's alloted display time +class SlideshowProgressBar extends ConsumerWidget { + final BaseAsset asset; + final Animation progress; + + const SlideshowProgressBar({super.key, required this.asset, required this.progress}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = this.asset; + + if (asset.isImage) { + return AnimatedBuilder(animation: progress, builder: (context, _) => _bar(context, progress.value)); + } + + final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position)); + return _bar(context, position.inMilliseconds / asset.duration.inMilliseconds); + } + + Widget _bar(BuildContext context, double value) { + return LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: BorderRadius.zero, + minHeight: 5, + value: value, + ); + } +} diff --git a/mobile/lib/presentation/widgets/slideshow/slideshow_slide.widget.dart b/mobile/lib/presentation/widgets/slideshow/slideshow_slide.widget.dart new file mode 100644 index 00000000000000..de92be81f238f8 --- /dev/null +++ b/mobile/lib/presentation/widgets/slideshow/slideshow_slide.widget.dart @@ -0,0 +1,154 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +void _noop() {} + +/// A single slideshow slide +class SlideshowSlide extends StatelessWidget { + static const double _kenBurnsZoomMultiplier = 0.1; + + final BaseAsset asset; + + final int index; + final SlideshowLook look; + final Animation zoom; + + final bool isCurrent; + final bool frozen; + + final VoidCallback onTapUp; + final VoidCallback onCompleted; + + const SlideshowSlide({ + super.key, + required this.asset, + required this.index, + required this.look, + required this.zoom, + required this.isCurrent, + required this.onTapUp, + required this.onCompleted, + }) : frozen = false; + + /// A static slide frozen at a given zoom level for use transitions + SlideshowSlide.frozen({super.key, required this.asset, required this.index, required this.look, required double zoom}) + : zoom = AlwaysStoppedAnimation(zoom), + isCurrent = false, + frozen = true, + onTapUp = _noop, + onCompleted = _noop; + + PhotoViewComputedScale get _scale => + look == SlideshowLook.cover ? PhotoViewComputedScale.covered : PhotoViewComputedScale.contained; + + @override + Widget build(BuildContext context) { + final Widget content = asset.isImage || frozen + ? ScaleTransition( + scale: zoom.drive(Tween(begin: 1.0, end: 1.0 + _kenBurnsZoomMultiplier)), + child: PhotoView( + imageProvider: getFullImageProvider(asset, size: context.sizeData), + index: index, + disableScaleGestures: true, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + initialScale: _scale, + controller: PhotoViewController(), + onTapUp: (_, _, _) => onTapUp(), + ), + ) + : _SlideshowVideo( + asset: asset, + isCurrent: isCurrent, + scale: _scale, + imageProvider: getFullImageProvider(asset, size: context.sizeData), + onTapUp: onTapUp, + onCompleted: onCompleted, + ); + + return Stack( + fit: StackFit.expand, + children: [ + if (look == SlideshowLook.blurredBackground) _BlurredBackground(asset: asset), + content, + ], + ); + } +} + +class _BlurredBackground extends StatelessWidget { + final BaseAsset asset; + + const _BlurredBackground({required this.asset}); + + @override + Widget build(BuildContext context) { + return ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: getFullImageProvider(asset, size: Size(context.width, context.height)), + fit: BoxFit.cover, + ), + ), + child: Container(color: Colors.black.withValues(alpha: 0.2)), + ), + ); + } +} + +class _SlideshowVideo extends ConsumerWidget { + final BaseAsset asset; + + final bool isCurrent; + final PhotoViewComputedScale scale; + + final ImageProvider imageProvider; + + final VoidCallback onTapUp; + final VoidCallback onCompleted; + + const _SlideshowVideo({ + required this.asset, + required this.isCurrent, + required this.scale, + required this.imageProvider, + required this.onTapUp, + required this.onCompleted, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) { + if (status == VideoPlaybackStatus.completed) { + if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) { + onCompleted(); + } + } + }); + + return PhotoView.customChild( + onTapUp: (_, _, _) => onTapUp(), + disableScaleGestures: true, + filterQuality: FilterQuality.high, + initialScale: scale, + child: NativeVideoViewer( + asset: asset, + isCurrent: isCurrent, + // Disable video looping + loopOverride: false, + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + ), + ); + } +} diff --git a/mobile/test/presentation/pages/slideshow_page_test.dart b/mobile/test/presentation/pages/slideshow_page_test.dart new file mode 100644 index 00000000000000..e1e49d452d078b --- /dev/null +++ b/mobile/test/presentation/pages/slideshow_page_test.dart @@ -0,0 +1,380 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/config/app_config.dart'; +import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/pages/slideshow.page.dart'; +import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_controller.dart'; +import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_slide.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; + +import '../../unit/factories/remote_asset_factory.dart'; +import '../../unit/presentation/presentation_context.dart'; + +/// A VideoPlayerNotifier that records play/pause events +class FakeVideoPlayer extends VideoPlayerNotifier { + /// Every call made against this video, in order + final calls = []; + + void emit(VideoPlayerState next) => state = next; + + @override + Future play() async => calls.add('play'); + + @override + Future pause() async => calls.add('pause'); +} + +const slideDuration = Duration(seconds: 3); + +/// A small step to precisely test event timing +const tick = Duration(milliseconds: 1); + +List images(int count) => List.generate(count, (_) => RemoteAssetFactory.create()); + +TimelineService timelineOf(List assets, {TimelineAssetSource? source}) => TimelineService(( + assetSource: source ?? (index, count) async => assets.sublist(index, math.min(index + count, assets.length)), + bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]), + origin: TimelineOrigin.main, +)); + +/// A timeline whose last asset is still loading +TimelineService partlyLoaded(List assets, {Completer? stall}) { + var loadCount = 0; + + return timelineOf( + assets, + source: (index, count) async { + loadCount += 1; + + if (loadCount != 1) { + await stall?.future; + } + + return assets.sublist(index, math.min(index + count, loadCount == 1 ? assets.length - 1 : assets.length)); + }, + ); +} + +void main() { + late PresentationContext ctx; + + setUp(() async => ctx = await PresentationContext.create()); + + tearDown(() async => ctx.dispose()); + + void slideshowTest( + String description, + Future Function(WidgetTester tester) body, { + TimelineService Function()? timeline, + SlideshowDirection direction = SlideshowDirection.forward, + bool repeat = true, + bool disableAnimations = false, + List overrides = const [], + }) { + testWidgets(description, (tester) async { + if (disableAnimations) { + tester.binding.platformDispatcher.accessibilityFeaturesTestValue = const FakeAccessibilityFeatures( + disableAnimations: true, + ); + } + + await tester.pumpTestWidget( + ctx, + SlideshowPage(timeline: (timeline ?? () => timelineOf(images(3)))()), + expectSettle: false, + overrides: [ + appConfigProvider.overrideWithValue( + AppConfig( + slideshow: SlideshowConfig(duration: 3, look: .contain, direction: direction, repeat: repeat), + ), + ), + ...overrides, + ], + ); + + await tester.pump(); + + await body(tester); + }); + } + + /// The delegate for this page + SlideshowDelegate delegateOf(WidgetTester tester) => + tester.state>(find.byType(SlideshowPage)) as SlideshowDelegate; + + /// The currently visible slide index + int visibleIndex(WidgetTester tester) => + tester.widgetList(find.byType(SlideshowSlide)).firstWhere((slide) => slide.isCurrent).index; + + /// The current value of the Ken Burns zoom for a particular slide + double currentZoomOf(WidgetTester tester, int index) => tester + .widget(find.byWidgetPredicate((w) => w is SlideshowSlide && w.index == index && !w.frozen)) + .zoom + .value; + + double? progressIndicatorProgress(WidgetTester tester) => + tester.widget(find.byType(LinearProgressIndicator)).value; + + final fadingSlide = find.byWidgetPredicate((w) => w is SlideshowSlide && w.frozen); + + Future tapScreen(WidgetTester tester) async { + await tester.tap(find.byType(PageView)); + + // The app bar does stuff in post frame, so we need to run twice + await tester.pump(); + await tester.pump(); + } + + group('basic rendering', () { + slideshowTest('should show the first slide only', (tester) async { + expect(visibleIndex(tester), 0); + expect(fadingSlide, findsNothing); + }); + + slideshowTest('should zoom the slide for its duration', (tester) async { + expect(currentZoomOf(tester, 0), 0.0); + + await tester.pump(slideDuration * 0.5); + // 50% through duration we should see 50% of the zoom completed + expect(currentZoomOf(tester, 0), closeTo(0.5, 0.01)); + }); + + slideshowTest('should reverse the zoom direction when moving to the next slide', (tester) async { + await tester.pump(slideDuration + tick); + await tester.pump(); + + // The previous slide zoomed in, so this one starts zoomed in and will zoom out + expect(currentZoomOf(tester, 1), closeTo(1.0, 0.01)); + + await tester.pump(slideDuration * 0.3); + expect(currentZoomOf(tester, 1), closeTo(0.7, 0.01)); + }); + + slideshowTest('should show the next slide once the duration passes', (tester) async { + await tester.pump(slideDuration - tick); + expect(visibleIndex(tester), 0); + + await tester.pump(tick * 2); + expect(visibleIndex(tester), 1); + }); + + slideshowTest('should fade the outgoing slide over the incoming one', (tester) async { + await tester.pump(slideDuration + tick); + await tester.pump(); + + // The old slide covers the new one, then is removed + expect(tester.widget(fadingSlide).index, 0); + expect(visibleIndex(tester), 1); + + final fade = find.ancestor(of: fadingSlide, matching: find.byType(FadeTransition)).first; + expect(tester.widget(fade).opacity.value, 1.0); + + await tester.pump(Durations.extralong2 * 0.5); + expect(tester.widget(fade).opacity.value, lessThan(1.0)); + + await tester.pump(Durations.extralong2); + expect(fadingSlide, findsNothing); + }); + + slideshowTest('should not fade when animations are disabled', (tester) async { + await tester.pump(slideDuration + tick); + await tester.pump(); + + expect(visibleIndex(tester), 1); + expect(fadingSlide, findsNothing); + + expect(currentZoomOf(tester, 1), 0.0); + + await tester.pump(slideDuration * 0.5); + expect(currentZoomOf(tester, 1), 0.0); + }, disableAnimations: true); + + slideshowTest('should show a loading indicator for a slide that isnt ready', (tester) async { + await tester.drag(find.byType(PageView), const Offset(-800, 0)); + await tester.pump(); + + expect(find.byType(ImmichLoadingIndicator), findsOneWidget); + }, timeline: () => partlyLoaded(images(2), stall: Completer())); + }); + + group('interactions', () { + slideshowTest('should advance the progress bar', (tester) async { + expect(progressIndicatorProgress(tester), 0.0); + + await tester.pump(slideDuration * 0.45); + expect(progressIndicatorProgress(tester), closeTo(0.45, 0.01)); + }); + + slideshowTest('should show/hide the AppBar', (tester) async { + final appBar = find.byType(AnimatedOpacity).first; + + expect(tester.widget(appBar).opacity, 0.0); + + await tapScreen(tester); + expect(tester.widget(appBar).opacity, 1.0); + + await tapScreen(tester); + expect(tester.widget(appBar).opacity, 0.0); + }); + + slideshowTest('should stop slide movement and progress bar when paused', (tester) async { + await tester.pump(slideDuration * 0.5); + + // Show AppBar and pause the app + await tapScreen(tester); + await tester.tap(find.byIcon(Icons.pause)); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + + final held = progressIndicatorProgress(tester); + await tester.pump(slideDuration); + expect(progressIndicatorProgress(tester), held); + expect(visibleIndex(tester), 0); + }); + + slideshowTest('should resume slide from its paused position', (tester) async { + await tester.pump(slideDuration * 0.64); + + await tapScreen(tester); + await tester.tap(find.byIcon(Icons.pause)); + await tester.pump(); + await tester.tap(find.byIcon(Icons.play_arrow)); + + // Resuming restarts the ticker, so this frame is its new baseline and elapses nothing + await tester.pump(); + + // We should have precisely 36% left before transition + await tester.pump(slideDuration * 0.36 - tick); + expect(visibleIndex(tester), 0); + + await tester.pump(tick * 2); + expect(visibleIndex(tester), 1); + }); + + late Completer arrived; + + slideshowTest('should properly handle an async load the user navigated away from', (tester) async { + await tester.pump(slideDuration + tick); + expect(visibleIndex(tester), 1); + + // This slide will get stuck + await tester.pump(slideDuration + tick); + + // Since the slide is stuck, the user swipes back to the first asset + await tester.drag(find.byType(PageView), const Offset(800, 0)); + await tester.pump(); + expect(visibleIndex(tester), 0); + + // The stuck slide loads, but it doesn't matter + arrived.complete(); + await tester.pump(); + await tester.pump(); + expect(visibleIndex(tester), 0); + }, timeline: () => partlyLoaded(images(3), stall: arrived = Completer())); + + slideshowTest('should start the slide timer when navigating to a new slide', (tester) async { + await tester.pump(slideDuration * 0.75); + + await tester.drag(find.byType(PageView), const Offset(-800, 0)); + await tester.pump(); + + expect(visibleIndex(tester), 1); + expect(progressIndicatorProgress(tester), 0.0); + + await tester.pump(slideDuration * 0.35); + expect(progressIndicatorProgress(tester), closeTo(0.35, 0.01)); + }); + }); + + group('delegate API', () { + final video = RemoteAssetFactory.create(type: .video); + + late FakeVideoPlayer player; + + final playing = [videoPlayerProvider(video.id).overrideWith((ref) => player)]; + + TimelineService withVideo() => timelineOf([video, ...images(1)]); + + setUp(() => player = FakeVideoPlayer()); + + slideshowTest('should wrap past end', (tester) async { + expect(delegateOf(tester).nextIndexAfter(0), 1); + expect(delegateOf(tester).nextIndexAfter(2), 0); + }); + + slideshowTest('should wrap past start', (tester) async { + expect(delegateOf(tester).nextIndexAfter(2), 1); + expect(delegateOf(tester).nextIndexAfter(0), 2); + }, direction: .backward); + + slideshowTest('should end the slideshow at the last slide when repeat is disabled', (tester) async { + expect(delegateOf(tester).nextIndexAfter(1), 2); + expect(delegateOf(tester).nextIndexAfter(2), null); + }, repeat: false); + + slideshowTest( + 'should end the slideshow at the first slide when running backwards', + (tester) async { + expect(delegateOf(tester).nextIndexAfter(1), 0); + expect(delegateOf(tester).nextIndexAfter(0), null); + }, + repeat: false, + direction: .backward, + ); + + slideshowTest('should end the slideshow when there is nothing to show', (tester) async { + expect(delegateOf(tester).nextIndexAfter(0), null); + }, timeline: () => timelineOf(const [])); + + slideshowTest( + 'should provide video playback position, but not image positions', + (tester) async { + player.emit(const VideoPlayerState(position: Duration(seconds: 2), duration: .zero, status: .playing)); + + expect(delegateOf(tester).videoProgressOf(0), const Duration(seconds: 2)); + expect(delegateOf(tester).videoProgressOf(1), null); + }, + timeline: withVideo, + overrides: playing, + ); + + slideshowTest( + 'should report when videos complete', + (tester) async { + expect(delegateOf(tester).isVideoCompleted(0), false); + + player.emit(const VideoPlayerState(position: Duration(seconds: 2), duration: .zero, status: .completed)); + expect(delegateOf(tester).isVideoCompleted(0), true); + expect(delegateOf(tester).isVideoCompleted(1), false); + }, + timeline: withVideo, + overrides: playing, + ); + + slideshowTest( + 'should pause and play the video along with the slideshow', + (tester) async { + delegateOf(tester).onPlaybackChanged(0, false); + delegateOf(tester).onPlaybackChanged(0, true); + expect(player.calls, ['pause', 'play']); + + // An image does nothing here + delegateOf(tester).onPlaybackChanged(1, true); + expect(player.calls, ['pause', 'play']); + }, + timeline: withVideo, + overrides: playing, + ); + }); +} diff --git a/mobile/test/presentation/widgets/slideshow/slideshow_controller_test.dart b/mobile/test/presentation/widgets/slideshow/slideshow_controller_test.dart new file mode 100644 index 00000000000000..f4aaf55b88ffff --- /dev/null +++ b/mobile/test/presentation/widgets/slideshow/slideshow_controller_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_controller.dart'; + +const slideDuration = Duration(seconds: 5); +const tick = Duration(milliseconds: 1); + +class FakeSlideshow implements SlideshowDelegate { + FakeSlideshow({this.nextIndex = _nextInOrder}) { + controller = SlideshowController( + vsync: const TestVSync(), + slideDuration: slideDuration, + initialIndex: 0, + delegate: this, + ); + } + + static int? _nextInOrder(int index) => index + 1; + + int? Function(int index) nextIndex; + + /// Video slide index -> playback position. Image slides do not appear in this map at all + final videos = {}; + final completedVideos = {}; + + /// Test action log + final events = []; + + late final SlideshowController controller; + + @override + int? nextIndexAfter(int index) => nextIndex(index); + + @override + Duration? videoProgressOf(int index) => videos[index]; + + @override + bool isVideoCompleted(int index) => completedVideos.contains(index); + + @override + void onPlaybackChanged(int index, bool playing) => events.add(playing ? 'play' : 'pause'); + + @override + void onShowSlide(int index, int prevIndex) { + events.add('show $index'); + + controller.didCompleteShowSlide(index); + } +} + +/// Runs [body] against a freshly started slideshow +void slideshowTest( + String description, + Future Function(WidgetTester tester, FakeSlideshow show) body, { + int? Function(int index)? nextIndex, +}) { + testWidgets(description, (tester) async { + final show = FakeSlideshow(nextIndex: nextIndex ?? FakeSlideshow._nextInOrder); + + // Go to first slide + show.controller.goToNextSlide(); + + // The slideshow's ticker takes its baseline on this frame, so a test can jump the clock straight away + await tester.pump(); + + try { + await body(tester, show); + } finally { + // The slideshow ticks forever. All tests must stop it, otherwise it will leak into subsequent tests + // This cannot appear in tearDown due to invariants being called outside of "render" + show.controller.dispose(); + } + }); +} + +void main() { + slideshowTest('should advance once the slide duration passes', (tester, show) async { + await tester.pump(slideDuration - tick); + expect(show.events, ['show 0']); + + await tester.pump(tick * 2); + expect(show.events, ['show 0', 'show 1']); + }); + + slideshowTest('should show the slide the delegate provides next', (tester, show) async { + await tester.pump(slideDuration + tick); + + expect(show.events, ['show 0', 'show 7']); + }, nextIndex: (_) => 7); + + slideshowTest('should pause when there is no next slide', (tester, show) async { + await tester.pump(slideDuration + tick); + + expect(show.events, ['show 0']); + expect(show.controller.paused, isTrue); + }, nextIndex: (_) => null); + + slideshowTest('should stop the clock when paused', (tester, show) async { + show.controller.pause(); + + expect(show.events, ['show 0', 'pause']); + + await tester.pump(slideDuration * 2); + + expect(show.events, ['show 0', 'pause']); + }); + + slideshowTest('should continue the current slide when resumed', (tester, show) async { + await tester.pump(slideDuration * 0.75); + + show.controller.pause(); + show.controller.resume(); + + // Resuming restarts the ticker, so this frame is its new baseline and elapses nothing + await tester.pump(); + + // A quarter of the slide is left + await tester.pump(slideDuration * 0.25 - tick); + expect(show.events, ['show 0', 'pause', 'play']); + + await tester.pump(tick * 2); + expect(show.events, ['show 0', 'pause', 'play', 'show 1']); + }); + + slideshowTest('should not watchdog modify a video that is successfully playing back', (tester, show) async { + show.videos[0] = Duration.zero; + + for (var i = 1; i <= 3; i++) { + // Simulate progress + show.videos[0] = slideDuration * i; + + await tester.pump(slideDuration + tick); + } + + expect(show.events, ['show 0']); + }); + + slideshowTest('should advance past a stalled video once the slide duration passes', (tester, show) async { + show.videos[0] = Duration.zero; + + await tester.pump(slideDuration - tick); + expect(show.events, ['show 0']); + + await tester.pump(tick * 2); + expect(show.events, ['show 0', 'show 1']); + }); + + slideshowTest('should advance immediately when a video ends', (tester, show) async { + show.videos[0] = Duration.zero; + + show.controller.didCompleteVideo(); + + expect(show.events, ['show 0', 'show 1']); + }); + + slideshowTest('should not move when a video ends while paused', (tester, show) async { + show.videos[0] = Duration.zero; + show.controller.pause(); + + show.controller.didCompleteVideo(); + + expect(show.events, ['show 0', 'pause']); + }); + + slideshowTest('should move to next slide when a finished video is resumed', (tester, show) async { + show.videos[0] = Duration.zero; + show.controller.pause(); + show.completedVideos.add(0); + + expect(show.events, ['show 0', 'pause']); + + show.controller.resume(); + + expect(show.events, ['show 0', 'pause', 'show 1']); + }); + + slideshowTest('should restart the timer on manual navigation to another slide', (tester, show) async { + await tester.pump(slideDuration * 0.60); + + // Widget informs us of a slide change + show.controller.didCompleteShowSlide(2); + + // A new slide restarts the ticker, so this frame is its new baseline and elapses nothing + await tester.pump(); + + // The new slide gets a full slide duration + await tester.pump(slideDuration - tick); + expect(show.events, ['show 0']); + + await tester.pump(tick * 2); + expect(show.events, ['show 0', 'show 3']); + }); + + slideshowTest('should pick up a new next slide when recalculating', (tester, show) async { + expect(show.controller.currentIndex, 0); + expect(show.controller.nextIndex, 1); + + show.nextIndex = (_) => 7; + show.controller.recalculateNextIndex(); + + expect(show.controller.nextIndex, 7); + }); +} diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 66e2874e1be0fe..55d7d3f5ed4835 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -178,6 +178,7 @@ class ServiceMocks { } void _stubAssetService() { + when(asset.getAsset).thenAnswer((_) async => null); when(asset.update).thenAnswer((_) async {}); when(asset.stack).thenAnswer((_) async {}); when(asset.unstack).thenAnswer((_) async {}); @@ -343,6 +344,9 @@ extension type const UserServiceStub(MockUserService service) implements Stub { + Future Function() get getAsset => + () => service.getAsset(any()); + Future Function() get update => () => service.update( any(), diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 8b576f3ac6e22f..8563900ea731c7 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/locales.dart'; @@ -90,6 +91,12 @@ class PresentationContext { void setup() { when(service.user.tryGetMyUser).thenReturn(currentUser); + + // Handle system chrome messages + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); } Future dispose() async { @@ -99,7 +106,15 @@ class PresentationContext { } extension PumpPresentationWidget on WidgetTester { - Future pumpTestWidget(PresentationContext context, Widget widget, {List overrides = const []}) async { + /// Renders the UI from the given [widget] + /// + /// Provide [expectSettle] `false` for a component that infinitely animates + Future pumpTestWidget( + PresentationContext context, + Widget widget, { + List overrides = const [], + bool expectSettle = true, + }) async { await pumpWidget( EasyLocalization( supportedLocales: locales.values.toList(), @@ -127,7 +142,12 @@ extension PumpPresentationWidget on WidgetTester { ), ), ); - await pumpAndSettle(); + + if (expectSettle) { + await pumpAndSettle(); + } else { + await pump(); + } } Future pumpTestAction(