This commit is contained in:
Santo Shakil 2026-08-12 15:39:04 +06:00 committed by GitHub
commit 8e3252b90e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 378 additions and 43 deletions

View file

@ -43,6 +43,8 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
late Timer _timer;
late int _index;
late int _nextIndex;
Duration _lastPosition = Duration.zero;
ProviderSubscription<VideoPlayerState>? _videoSubscription;
bool _paused = false;
bool _showAppBar = false;
@ -64,6 +66,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_crossfadeOpacity = Tween<double>(begin: 1.0, end: 0.0).animate(_crossfadeController);
_stopwatch = Stopwatch();
_createTimer();
_watchVideo();
_updateNextIndex();
ref.listenManual(appConfigProvider.select((s) => s.slideshow), _onConfigChanged);
@ -89,21 +92,22 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
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;
});
final asset = widget.timeline.getAssetSafe(_index)!;
if (!asset.isImage) {
final player = ref.read(videoPlayerProvider(asset.id));
if (player.status == VideoPlaybackStatus.completed && player.position > Duration.zero) {
unawaited(_nextPage());
return;
}
unawaited(ref.read(videoPlayerProvider(asset.id).notifier).play());
}
_createTimer();
}
void _pause() {
@ -130,8 +134,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_config = next;
_updateNextIndex();
final asset = widget.timeline.getAssetSafe(_index);
if (durationChanged && !_paused && asset?.isImage == true) {
if (durationChanged && !_paused) {
_timer.cancel();
_createTimer();
}
@ -152,24 +155,45 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
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;
});
}
_timer.cancel();
if (widget.timeline.totalAssets == 0) {
return;
}
if (!widget.timeline.hasRange(_nextIndex, 1)) {
await widget.timeline.preloadAssets(_nextIndex);
var target = _nextIndex;
if (target < 0 || target >= widget.timeline.totalAssets) {
if (!_config.repeat) {
setState(() {
_paused = true;
});
return;
}
target = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1;
}
_crossFadeToPage(_nextIndex);
if (!widget.timeline.hasRange(target, 1)) {
final from = _index;
await widget.timeline.preloadAssets(target);
if (!mounted || from != _index) {
return;
}
}
if (target == _index) {
final asset = widget.timeline.getAssetSafe(target)!;
if (!asset.isImage) {
final player = ref.read(videoPlayerProvider(asset.id));
if (player.status == VideoPlaybackStatus.completed && player.position > Duration.zero) {
unawaited(ref.read(videoPlayerProvider(asset.id).notifier).restart());
}
}
// jumpToPage to the current page fires no onPageChanged
_pageChanged(target);
return;
}
_crossFadeToPage(target);
}
void _crossFadeToPage(int page) {
@ -237,15 +261,60 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
void _createTimer() {
_timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () {
_stopwatch.stop();
_stopwatch.reset();
unawaited(_nextPage());
});
_timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), _onTimerElapsed);
_stopwatch.start();
}
void _onTimerElapsed() {
_stopwatch.stop();
_stopwatch.reset();
final asset = widget.timeline.getAssetSafe(_index);
if (asset != null && !asset.isImage) {
final position = ref.read(videoPlayerProvider(asset.id)).position;
if (position != _lastPosition) {
_lastPosition = position;
_createTimer();
return;
}
}
unawaited(_nextPage());
}
void _watchVideo() {
_videoSubscription?.close();
_videoSubscription = null;
final asset = widget.timeline.getAssetSafe(_index);
if (asset == null || asset.isImage) {
return;
}
// a video already playing here (e.g. opened from the viewer) never gets a
// playing transition, so its loop has to be turned off up front
if (ref.read(videoPlayerProvider(asset.id)).status == VideoPlaybackStatus.playing) {
unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false));
}
_videoSubscription = ref.listenManual(
videoPlayerProvider(asset.id),
(previous, next) => _onVideoChanged(asset.id, previous, next),
);
}
void _onVideoChanged(String assetId, VideoPlayerState? previous, VideoPlayerState next) {
if (_paused || next.status == previous?.status) {
return;
}
if (next.status == VideoPlaybackStatus.completed && next.position > Duration.zero) {
unawaited(_nextPage());
} else if (next.status == VideoPlaybackStatus.playing) {
unawaited(ref.read(videoPlayerProvider(assetId).notifier).setLoop(false));
}
}
void _pageChanged(int page) {
final asset = widget.timeline.getAssetSafe(page)!;
@ -261,8 +330,10 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_timer.cancel();
_stopwatch.stop();
_stopwatch.reset();
_lastPosition = Duration.zero;
_watchVideo();
if (!_paused && asset.isImage) {
if (!_paused) {
_createTimer();
}
@ -374,15 +445,6 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
);
} else {
final status = ref.read(videoPlayerProvider(asset.id).select((s) => s.status));
final position = ref.read(videoPlayerProvider(asset.id)).position;
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
unawaited(_nextPage());
} else if (status == VideoPlaybackStatus.playing) {
unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false));
}
return PhotoView.customChild(
onTapUp: (_, _, _) => _onTapUp(),
disableScaleGestures: true,

View file

@ -0,0 +1,273 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/foundation.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/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/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../service.mocks.dart';
import '../../unit/factories/remote_asset_factory.dart';
import '../../widget_tester_extensions.dart';
class MockTimelineService extends Mock implements TimelineService {}
class FakeVideoPlayerNotifier extends VideoPlayerNotifier {
final loopCalls = <bool>[];
var restartCalls = 0;
void emit(VideoPlayerState next) => state = next;
@override
Future<void> setLoop(bool loop) async => loopCalls.add(loop);
@override
Future<void> restart() async => restartCalls++;
}
void main() {
const config = AppConfig(slideshow: SlideshowConfig(duration: 3, look: SlideshowLook.contain));
const duration = Duration(seconds: 3);
late MockTimelineService timeline;
late MockAssetService assetService;
late FakeVideoPlayerNotifier player;
setUpAll(() async {
registerFallbackValue(RemoteAssetFactory.create());
final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
await StoreService.I.put(StoreKey.serverEndpoint, 'http://localhost:3000');
});
setUp(() {
timeline = MockTimelineService();
assetService = MockAssetService();
player = FakeVideoPlayerNotifier();
when(() => assetService.getAsset(any())).thenAnswer((_) async => null);
// Pigeon success replies with a null payload: images and wakelock toggles resolve to nothing.
Future<ByteData?> nullReply(ByteData? message) async => const StandardMessageCodec().encodeMessage(<Object?>[null]);
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMessageHandler(
'dev.flutter.pigeon.wakelock_plus_platform_interface.WakelockPlusApi.toggle',
nullReply,
);
messenger.setMockMessageHandler('dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage', nullReply);
messenger.setMockMessageHandler('dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest', nullReply);
messenger.setMockMethodCallHandler(
SystemChannels.platform_views,
(call) async => switch (call.method) {
'create' => 1,
'resize' => <String, Object?>{'width': 1080.0, 'height': 1920.0},
_ => null,
},
);
});
void stubTimeline(List<BaseAsset> assets) {
when(() => timeline.totalAssets).thenReturn(assets.length);
when(() => timeline.hasRange(any(), any())).thenReturn(true);
when(() => timeline.preloadAssets(any())).thenAnswer((_) async {});
when(() => timeline.getAssetSafe(any())).thenAnswer((invocation) {
final index = invocation.positionalArguments.first as int;
return index >= 0 && index < assets.length ? assets[index] : null;
});
}
// The page never settles while running (progress bar / spinner animations), so
// mount it with a plain pump after the localized shell settled around a placeholder.
Future<void> pumpSlideshow(WidgetTester tester, {required List<Override> overrides}) async {
final show = ValueNotifier(false);
await tester.pumpConsumerWidget(
ValueListenableBuilder<bool>(
valueListenable: show,
builder: (context, value, _) => !value
? const SizedBox.shrink()
: MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: DriftSlideshowPage(timeline: timeline),
),
),
overrides: overrides,
);
show.value = true;
await tester.pump();
}
double currentPage(WidgetTester tester) => tester.widget<PageView>(find.byType(PageView)).controller!.page!;
List<Override> overridesFor(RemoteAsset video) => [
appConfigProvider.overrideWithValue(config),
assetServiceProvider.overrideWithValue(assetService),
gCastServiceProvider.overrideWithValue(MockGCastService()),
videoPlayerProvider(video.id).overrideWith((ref) => player),
];
testWidgets('video that never starts advances after one duration', (tester) async {
final video = RemoteAssetFactory.create(type: .video);
stubTimeline([RemoteAssetFactory.create(), video, RemoteAssetFactory.create()]);
await pumpSlideshow(tester, overrides: overridesFor(video));
await tester.pump(duration);
expect(currentPage(tester), 1);
player.emit(const VideoPlayerState(position: Duration.zero, duration: Duration.zero, status: .buffering));
await tester.pump(duration);
expect(currentPage(tester), 2);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('completed at position zero does not skip the video', (tester) async {
final video = RemoteAssetFactory.create(type: .video);
stubTimeline([RemoteAssetFactory.create(), video, RemoteAssetFactory.create()]);
await pumpSlideshow(tester, overrides: overridesFor(video));
await tester.pump(duration);
expect(currentPage(tester), 1);
player.emit(const VideoPlayerState(position: Duration.zero, duration: Duration(seconds: 60), status: .completed));
await tester.pump();
expect(currentPage(tester), 1);
expect(player.restartCalls, 0);
await tester.pump(duration);
expect(currentPage(tester), 2);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('playing video is not cut short and advances when it ends', (tester) async {
final video = RemoteAssetFactory.create(type: .video);
stubTimeline([RemoteAssetFactory.create(), video, RemoteAssetFactory.create()]);
await pumpSlideshow(tester, overrides: overridesFor(video));
await tester.pump(duration);
expect(currentPage(tester), 1);
const length = Duration(seconds: 60);
player.emit(const VideoPlayerState(position: Duration(milliseconds: 500), duration: length, status: .playing));
await tester.pump(duration);
expect(currentPage(tester), 1);
player.emit(const VideoPlayerState(position: Duration(seconds: 2), duration: length, status: .playing));
await tester.pump(duration);
expect(currentPage(tester), 1);
player.emit(const VideoPlayerState(position: length, duration: length, status: .completed));
await tester.pump();
expect(currentPage(tester), 2);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('disables video looping once playback starts', (tester) async {
final video = RemoteAssetFactory.create(type: .video);
stubTimeline([RemoteAssetFactory.create(), video]);
await pumpSlideshow(tester, overrides: overridesFor(video));
await tester.pump(duration);
expect(currentPage(tester), 1);
player.emit(const VideoPlayerState(position: Duration.zero, duration: Duration(seconds: 60), status: .playing));
await tester.pump();
expect(player.loopCalls, [false]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('disables looping for a video already playing at entry', (tester) async {
final video = RemoteAssetFactory.create(type: .video);
stubTimeline([RemoteAssetFactory.create(), video]);
player.emit(const VideoPlayerState(position: Duration.zero, duration: Duration(seconds: 60), status: .playing));
await pumpSlideshow(tester, overrides: overridesFor(video));
await tester.pump(duration);
expect(currentPage(tester), 1);
expect(player.loopCalls, [false]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('stops on the last slide when repeat is off', (tester) async {
const noRepeat = AppConfig(slideshow: SlideshowConfig(duration: 3, look: SlideshowLook.contain, repeat: false));
stubTimeline([RemoteAssetFactory.create(), RemoteAssetFactory.create()]);
await pumpSlideshow(
tester,
overrides: [
appConfigProvider.overrideWithValue(noRepeat),
assetServiceProvider.overrideWithValue(assetService),
gCastServiceProvider.overrideWithValue(MockGCastService()),
],
);
await tester.pump(duration);
expect(currentPage(tester), 1);
await tester.pump(duration);
await tester.pump(duration);
expect(currentPage(tester), 1);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('wraps to the first slide when repeat is on', (tester) async {
stubTimeline([RemoteAssetFactory.create(), RemoteAssetFactory.create()]);
await pumpSlideshow(
tester,
overrides: [
appConfigProvider.overrideWithValue(config),
assetServiceProvider.overrideWithValue(assetService),
gCastServiceProvider.overrideWithValue(MockGCastService()),
],
);
await tester.pump(duration);
expect(currentPage(tester), 1);
await tester.pump(duration);
expect(currentPage(tester), 0);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('single ended video on repeat restarts in place', (tester) async {
final video = RemoteAssetFactory.create(type: .video);
stubTimeline([video]);
await pumpSlideshow(tester, overrides: overridesFor(video));
const length = Duration(seconds: 60);
player.emit(const VideoPlayerState(position: Duration(seconds: 1), duration: length, status: .playing));
await tester.pump();
player.emit(const VideoPlayerState(position: length, duration: length, status: .completed));
await tester.pump();
expect(player.restartCalls, 1);
expect(currentPage(tester), 0);
player.emit(const VideoPlayerState(position: Duration.zero, duration: length, status: .playing));
await tester.pump();
player.emit(const VideoPlayerState(position: length, duration: length, status: .completed));
await tester.pump();
expect(player.restartCalls, 2);
expect(currentPage(tester), 0);
await tester.pumpWidget(const SizedBox.shrink());
});
}