This commit is contained in:
Adam Gastineau 2026-08-15 17:34:52 +06:00 committed by GitHub
commit 71fe50ebd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 628 additions and 480 deletions

View file

@ -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<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
}
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with SingleTickerProviderStateMixin {
static const double _kenBurnsZoom = 0.1;
late SlideshowConfig _config;
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage>
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<double> _crossfadeOpacity;
int? _crossfadeFromIndex;
int? _crossfadeToIndex;
int _zoomCycle = 0;
late final AnimationController _fade;
late final Animation<double> _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<double>(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<DriftSlideshowPage> 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<void> _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<void> _onTapUp() async {
@ -280,314 +228,115 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> 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<double> 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<double>(
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)),
),
),
],
),
);
},
);
}
}

View file

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

View file

@ -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<double> 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<void> 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());
}
}

View file

@ -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<double> 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,
);
}
}

View file

@ -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<double> 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),
),
);
}
}