From 4b27dac3f1b37f85e9a4ab1f449f75b8ae741985 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 14 Aug 2026 10:43:36 -0700 Subject: [PATCH] fix(mobile): rewrite slideshow controller system --- .../pages/drift_slideshow.page.dart | 707 ++++++------------ .../asset_viewer/video_viewer.widget.dart | 6 +- .../slideshow/slideshow_controller.dart | 207 +++++ .../slideshow_progress_bar.widget.dart | 34 + .../slideshow/slideshow_slide.widget.dart | 154 ++++ 5 files changed, 628 insertions(+), 480 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 diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 2662c60a30..43b20911e7 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_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,72 @@ class DriftSlideshowPage extends ConsumerStatefulWidget { ConsumerState createState() => _DriftSlideshowPageState(); } -class _DriftSlideshowPageState extends ConsumerState with SingleTickerProviderStateMixin { - static const double _kenBurnsZoom = 0.1; - - late SlideshowConfig _config; +class _DriftSlideshowPageState 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(); + }); unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive)); unawaited(WakelockPlus.enable()); @@ -80,194 +113,109 @@ class _DriftSlideshowPageState extends ConsumerState with Si @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()); + @override + int? nextIndexAfter(int index) { + if (widget.timeline.totalAssets == 0) { + return null; } - _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(); - } - - 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; + + // Do wrap + next = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1; } - if (!widget.timeline.hasRange(_nextIndex, 1)) { - await widget.timeline.preloadAssets(_nextIndex); - } - - _crossFadeToPage(_nextIndex); + return next; } - void _crossFadeToPage(int page) { - if (_disableAnimations) { - _pageController.jumpToPage(page); - return; + @override + Duration? videoProgressOf(int index) { + final video = _videoAt(index); + + if (video == null) { + return null; } - 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 _videoState(video).position; + } + + @override + bool isVideoCompleted(int index) { + final video = _videoAt(index); + + return video != null && _videoState(video).status == VideoPlaybackStatus.completed; + } + + @override + void onResumeSlide(int index) { + unawaited(_videoNotifier(index)?.play()); + } + + @override + void onPauseSlide(int index) { + unawaited(_videoNotifier(index)?.pause()); + } + + @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()); } - }), - ); - } - Widget _getCrossfadeLayer(BuildContext context, int index, {required bool isIncoming}) { - final asset = widget.timeline.getAssetSafe(index); + _slideshow.didCompleteShowSlide(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); - } - - 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() { - _timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () { - _stopwatch.stop(); - _stopwatch.reset(); - unawaited(_nextPage()); - }); - - _stopwatch.start(); - } - - void _pageChanged(int page) { - final asset = widget.timeline.getAssetSafe(page)!; - - setState(() { - _index = page; - _zoomCycle++; - - if (!asset.isImage) { - _paused = false; + return; } - }); - _timer.cancel(); - _stopwatch.stop(); - _stopwatch.reset(); + if (!widget.timeline.hasRange(index, 1)) { + await widget.timeline.preloadAssets(index); + } - if (!_paused && asset.isImage) { - _createTimer(); - } + if (!mounted) { + return; + } - _updateNextIndex(); + // didCompleteShowSlide is called by onPageChanged + if (_disableAnimations) { + _pageController.jumpToPage(index); + return; + } + + _pageController.jumpToPage(index); + + setState(() => _fadingSlideIndex = prevIndex); + + unawaited( + _fade.forward(from: 0.0).whenComplete(() { + if (mounted) { + setState(() => _fadingSlideIndex = null); + } + }), + ); + }()); } Future _onTapUp() async { @@ -280,314 +228,115 @@ class _DriftSlideshowPageState extends ConsumerState with Si }); } - 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); - } - } - - Widget _getBlur(BuildContext context, int index) { - final asset = widget.timeline.getAssetSafe(index); - - if (asset == null) { - return Container(); - } - - 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 _slideshow.animationController.drive( + _slideshow.shouldZoomOut ? Tween(begin: 1.0, end: 0.0) : Tween(begin: 0.0, end: 1.0), ); } - Widget _getPhotoView(BuildContext context, int index) { - final asset = widget.timeline.getAssetSafe(index); - + Widget _buildSlide(int index, SlideshowLook look) { + final asset = _assetAt(index); if (asset == null) { return const Center(child: ImmichLoadingIndicator()); } - final scale = _config.look == SlideshowLook.cover - ? PhotoViewComputedScale.covered - : PhotoViewComputedScale.contained; - final isCurrent = _index == index; - final imageProvider = getFullImageProvider(asset, size: context.sizeData); + return SlideshowSlide( + asset: asset, + index: index, + look: look, + zoom: _zoom, + isCurrent: _slideshow.currentIndex == index, + onTapUp: _onTapUp, + onCompleted: _slideshow.didCompleteVideo, + ); + } - 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, - ); + /// The outgoing slide, frozen in its last position + Widget _buildFadingSlide(int index, SlideshowLook look) { + final asset = _assetAt(index); + if (asset == null) { + return const SizedBox.shrink(); } + + 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), - ), - ], - ), - _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, + 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.animationController) + : 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: [ - const ColoredBox(color: Colors.black), - FadeTransition( - opacity: _crossfadeController, - child: _getCrossfadeLayer(context, _crossfadeToIndex!, isIncoming: true), - ), - FadeTransition( - opacity: _crossfadeOpacity, - child: _getCrossfadeLayer(context, _crossfadeFromIndex!, isIncoming: false), + 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, ], ), ), ), - ], - ), - ); - } -} - -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, - ), + ), + 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), + ), + ), + 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 9011888b54..3566c86c0a 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 @@ -274,7 +278,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 0000000000..4b99c4d15b --- /dev/null +++ b/mobile/lib/presentation/widgets/slideshow/slideshow_controller.dart @@ -0,0 +1,207 @@ +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 a slide resumes from a non-visible or non-playing state + void onResumeSlide(int index); + + /// Called when the slideshow is explicitly stopped + void onPauseSlide(int index); + + /// 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 +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 = false; + + /// 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(); + } + }); + + _nextIndex = delegate.nextIndexAfter(initialIndex); + + _startSlideTimer(); + } + + /// 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. Its value, [0.0, 1.0] represents the progress through the duration of the current slide + Animation get animationController => _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.onPauseSlide(_currentIndex); + + notifyListeners(); + } + + /// Resume the slideshow from its current position + void resume() { + _paused = false; + notifyListeners(); + + if (delegate.videoProgressOf(_currentIndex) != null && delegate.isVideoCompleted(_currentIndex)) { + unawaited(goToNextSlide()); + return; + } + + if (_animationController.isCompleted) { + // If slide hit the end, restart it from 0 + _animationController.value = 0.0; + } + + unawaited(_animationController.forward()); + + delegate.onResumeSlide(_currentIndex); + } + + /// Immediately transitions to the previously determined next slide + /// + /// If there is no [_nextIndex], pauses the slideshow + Future goToNextSlide() async { + _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) { + unawaited(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; + } + + unawaited(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 0000000000..f37deb4718 --- /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 0000000000..0609dbc1b7 --- /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 + ? AnimatedBuilder( + animation: zoom, + builder: (context, _) => PhotoView( + imageProvider: getFullImageProvider(asset, size: context.sizeData), + index: index, + disableScaleGestures: true, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + initialScale: _scale * (1.0 + zoom.value * _kenBurnsZoomMultiplier), + 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), + ), + ); + } +}