From 4eb08eee18806bd33e028b7edbe8dca65e0b39da Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:28:07 +0000 Subject: [PATCH] fix(mobile): video state (#26574) Consolidate video state into a single asset-scoped provider, and reduce dependency on global state generally. Overall this should fix a few timing issues and race conditions with videos specifically, and make future changes in this area easier. --- .../lib/pages/common/gallery_viewer.page.dart | 4 - .../common/native_video_viewer.page.dart | 176 +----- mobile/lib/pages/photos/memory.page.dart | 4 - .../presentation/pages/drift_memory.page.dart | 24 +- .../add_action_button.widget.dart | 10 +- .../edit_image_action_button.widget.dart | 4 +- .../like_activity_action_button.widget.dart | 4 +- .../similar_photos_action_button.widget.dart | 2 +- .../widgets/album/album_selector.widget.dart | 4 +- .../asset_viewer/asset_details.widget.dart | 22 +- .../appears_in_details.widget.dart | 20 +- .../date_time_details.widget.dart | 18 +- .../location_details.widget.dart | 28 +- .../asset_details/people_details.widget.dart | 16 +- .../asset_details/rating_details.widget.dart | 8 +- .../technical_details.widget.dart | 11 +- .../asset_viewer/asset_page.widget.dart | 184 +++--- .../asset_viewer/asset_stack.widget.dart | 72 +-- .../asset_viewer/asset_viewer.page.dart | 105 ++-- .../asset_viewer/bottom_bar.widget.dart | 9 +- .../asset_viewer/video_viewer.widget.dart | 583 ++++++------------ .../video_viewer_controls.widget.dart | 75 ++- .../viewer_bottom_app_bar.widget.dart | 21 +- .../viewer_kebab_menu.widget.dart | 4 +- .../viewer_top_app_bar.widget.dart | 12 +- .../widgets/images/thumbnail_tile.widget.dart | 2 +- .../widgets/memory/memory_card.widget.dart | 29 +- .../asset_viewer/asset_viewer.provider.dart} | 28 +- .../video_player_controls_provider.dart | 71 --- .../asset_viewer/video_player_provider.dart | 200 ++++++ .../video_player_value_provider.dart | 88 --- .../infrastructure/action.provider.dart | 12 +- .../asset_viewer/asset.provider.dart | 50 +- mobile/lib/utils/hooks/interval_hook.dart | 15 - .../asset_viewer/bottom_gallery_bar.dart | 2 +- .../custom_video_player_controls.dart | 41 +- .../widgets/asset_viewer/video_controls.dart | 13 +- .../widgets/asset_viewer/video_position.dart | 22 +- mobile/lib/widgets/memories/memory_lane.dart | 4 - mobile/pubspec.lock | 8 +- 40 files changed, 823 insertions(+), 1182 deletions(-) rename mobile/lib/{presentation/widgets/asset_viewer/asset_viewer.state.dart => providers/asset_viewer/asset_viewer.provider.dart} (79%) delete mode 100644 mobile/lib/providers/asset_viewer/video_player_controls_provider.dart create mode 100644 mobile/lib/providers/asset_viewer/video_player_provider.dart delete mode 100644 mobile/lib/providers/asset_viewer/video_player_value_provider.dart delete mode 100644 mobile/lib/utils/hooks/interval_hook.dart diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 0ef27f854b..1d43bff167 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -367,9 +366,6 @@ class GalleryViewerPage extends HookConsumerWidget { stackIndex.value = 0; ref.read(currentAssetProvider.notifier).set(newAsset); - if (newAsset.isVideo || newAsset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } // Wait for page change animation to finish, then precache the next image Timer(const Duration(milliseconds: 400), () { diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 9cd9f6bd5e..b1eed29c5c 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -11,18 +11,14 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; -import 'package:immich_mobile/utils/debounce.dart'; -import 'package:immich_mobile/utils/hooks/interval_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() class NativeVideoViewerPage extends HookConsumerWidget { @@ -42,18 +38,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final videoId = asset.id.toString(); final controller = useState(null); - final lastVideoPosition = useRef(-1); - final isBuffering = useRef(false); - - // Used to track whether the video should play when the app - // is brought back to the foreground final shouldPlayOnForeground = useRef(true); - // When a video is opened through the timeline, `isCurrent` will immediately be true. - // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. - // If the swipe is completed, `isCurrent` will be true for video B after a delay. - // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. final currentAsset = useState(ref.read(currentAssetProvider)); final isCurrent = currentAsset.value == asset; @@ -117,127 +105,45 @@ class NativeVideoViewerPage extends HookConsumerWidget { } }); - void checkIfBuffering() { - if (!context.mounted) { - return; - } - - final videoPlayback = ref.read(videoPlaybackValueProvider); - if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) && - videoPlayback.state != VideoPlaybackState.buffering) { - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith( - state: VideoPlaybackState.buffering, - ); - } - } - - // Timer to mark videos as buffering if the position does not change - useInterval(const Duration(seconds: 5), checkIfBuffering); - - // When the position changes, seek to the position - // Debounce the seek to avoid seeking too often - // But also don't delay the seek too much to maintain visual feedback - final seekDebouncer = useDebouncer( - interval: const Duration(milliseconds: 100), - maxWaitTime: const Duration(milliseconds: 200), - ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { - final playerController = controller.value; - if (playerController == null) { - return; - } - - final playbackInfo = playerController.playbackInfo; - if (playbackInfo == null) { - return; - } - - final oldSeek = oldControls?.position.inMilliseconds; - final newSeek = newControls.position.inMilliseconds; - if (oldSeek != newSeek || newControls.restarted) { - seekDebouncer.run(() => playerController.seekTo(newSeek)); - } - - if (oldControls?.pause != newControls.pause || newControls.restarted) { - unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); - } - }); - void onPlaybackReady() async { final videoController = controller.value; if (videoController == null || !isCurrent || !context.mounted) { return; } - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + final notifier = ref.read(videoPlayerProvider(videoId).notifier); + notifier.onNativePlaybackReady(); isVideoReady.value = true; try { final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.autoPlayVideo); if (autoPlayVideo) { - await videoController.play(); + await notifier.play(); } - await videoController.setVolume(0.9); + await notifier.setVolume(1); } catch (error) { log.severe('Error playing video: $error'); } } void onPlaybackStatusChanged() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - if (videoPlayback.state == VideoPlaybackState.playing) { - // Sync with the controls playing - WakelockPlus.enable(); - } else { - // Sync with the controls pause - WakelockPlus.disable(); - } - - ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state; + if (!context.mounted) return; + ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged(); } void onPlaybackPositionChanged() { - // When seeking, these events sometimes move the slider to an older position - if (seekDebouncer.isActive) { - return; - } - - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final playbackInfo = videoController.playbackInfo; - if (playbackInfo == null) { - return; - } - - ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); - - // Check if the video is buffering - if (playbackInfo.status == PlaybackStatus.playing) { - isBuffering.value = lastVideoPosition.value == playbackInfo.position; - lastVideoPosition.value = playbackInfo.position; - } else { - isBuffering.value = false; - lastVideoPosition.value = -1; - } + if (!context.mounted) return; + ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged(); } void onPlaybackEnded() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } + if (!context.mounted) return; - if (videoController.playbackInfo?.status == PlaybackStatus.stopped && + ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded(); + + final videoController = controller.value; + if (videoController?.playbackInfo?.status == PlaybackStatus.stopped && !ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo)) { ref.read(isPlayingMotionVideoProvider.notifier).playing = false; } @@ -254,14 +160,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { if (controller.value != null || !context.mounted) { return; } - ref.read(videoPlayerControlsProvider.notifier).reset(); - ref.read(videoPlaybackValueProvider.notifier).reset(); final source = await videoSource; if (source == null) { return; } + final notifier = ref.read(videoPlayerProvider(videoId).notifier); + notifier.attachController(nc); + nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackReady.addListener(onPlaybackReady); @@ -273,10 +180,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { }), ); final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); - unawaited(nc.setLoop(loopVideo)); + await notifier.setLoop(loopVideo); controller.value = nc; - Timer(const Duration(milliseconds: 200), checkIfBuffering); } ref.listen(currentAssetProvider, (_, value) { @@ -300,10 +206,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { } // Delay the video playback to avoid a stutter in the swipe animation - // Note, in some circumstances a longer delay is needed (eg: memories), - // the playbackDelayFactor can be used for this - // This delay seems like a hacky way to resolve underlying bugs in video - // playback, but other resolutions failed thus far Timer( Platform.isIOS ? Duration(milliseconds: 300 * playbackDelayFactor) @@ -337,19 +239,18 @@ class NativeVideoViewerPage extends HookConsumerWidget { playerController.stop().catchError((error) { log.fine('Error stopping video: $error'); }); - - WakelockPlus.disable(); }; }, const []); useOnAppLifecycleStateChange((_, state) async { + final notifier = ref.read(videoPlayerProvider(videoId).notifier); if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) { - await controller.value?.play(); + await notifier.play(); } else if (state == AppLifecycleState.paused) { final videoPlaying = await controller.value?.isPlaying(); if (videoPlaying ?? true) { shouldPlayOnForeground.value = true; - await controller.value?.pause(); + await notifier.pause(); } else { shouldPlayOnForeground.value = false; } @@ -374,39 +275,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { ), ), ), - if (showControls) const Center(child: CustomVideoPlayerControls()), + if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)), ], ); } - - Future _onPauseChange( - BuildContext context, - NativeVideoPlayerController controller, - Debouncer seekDebouncer, - bool isPaused, - ) async { - if (!context.mounted) { - return; - } - - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } - - if (!context.mounted) { - return; - } - - try { - if (isPaused) { - await controller.pause(); - } else { - await controller.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); - } - } } diff --git a/mobile/lib/pages/photos/memory.page.dart b/mobile/lib/pages/photos/memory.page.dart index 20bd32a171..bd7973bc21 100644 --- a/mobile/lib/pages/photos/memory.page.dart +++ b/mobile/lib/pages/photos/memory.page.dart @@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart'; @@ -166,9 +165,6 @@ class MemoryPage extends HookConsumerWidget { final asset = currentMemory.value.assets[otherIndex]; currentAsset.value = asset; ref.read(currentAssetProvider.notifier).set(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } } /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart index 147165f2a3..3f8879c91d 100644 --- a/mobile/lib/presentation/pages/drift_memory.page.dart +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -7,16 +7,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; -/// Expects [currentAssetNotifier] to be set before navigating to this page +/// Expects the current asset to be set via [assetViewerProvider] before navigating to this page @RoutePage() class DriftMemoryPage extends HookConsumerWidget { final List memories; @@ -26,11 +25,7 @@ class DriftMemoryPage extends HookConsumerWidget { static void setMemory(WidgetRef ref, DriftMemory memory) { if (memory.assets.isNotEmpty) { - ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first); - - if (memory.assets.first.isVideo) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } + ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first); } } @@ -172,11 +167,7 @@ class DriftMemoryPage extends HookConsumerWidget { final asset = currentMemory.value.assets[otherIndex]; currentAsset.value = asset; - ref.read(currentAssetNotifier.notifier).setAsset(asset); - // if (asset.isVideo || asset.isMotionPhoto) { - if (asset.isVideo) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } + ref.read(assetViewerProvider.notifier).setAsset(asset); } /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called @@ -273,7 +264,12 @@ class DriftMemoryPage extends HookConsumerWidget { children: [ Container( color: Colors.black, - child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0), + child: DriftMemoryCard( + asset: asset, + title: title, + showTitle: index == 0, + isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value, + ), ), Positioned.fill( child: Row( diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index 4162f43a24..39bdef8b9a 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState { } List _buildMenuChildren() { - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; if (asset == null) return []; final user = ref.read(currentUserProvider); @@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState { } void _openAlbumSelector() { - final currentAsset = ref.read(currentAssetNotifier); + final currentAsset = ref.read(assetViewerProvider).currentAsset; if (currentAsset == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); return; @@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState { } Future _addCurrentAssetToAlbum(RemoteAlbum album) async { - final latest = ref.read(currentAssetNotifier); + final latest = ref.read(assetViewerProvider).currentAsset; if (latest == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); @@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState { @override Widget build(BuildContext context) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 440985a0bb..cad74ce658 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class EditImageActionButton extends ConsumerWidget { @@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentAsset = ref.watch(currentAssetNotifier); + final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); onPress() { if (currentAsset == null) { diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index a44b0b5815..96a7daa327 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(currentRemoteAlbumProvider); - final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?; final user = ref.watch(currentUserProvider); final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id)); diff --git a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart index 294ddfd1f5..530c3fd8d4 100644 --- a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class SimilarPhotosActionButton extends ConsumerWidget { diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 15749fb9af..0c039847a4 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget { return; } - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; if (asset == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart index 949a6917e9..e07fd79192 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -1,5 +1,6 @@ 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/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart'; @@ -11,16 +12,15 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/te import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetDetails extends ConsumerWidget { + final BaseAsset asset; final double minHeight; - const AssetDetails({required this.minHeight, super.key}); + const AssetDetails({super.key, required this.asset, required this.minHeight}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } + final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull; + return Container( constraints: BoxConstraints(minHeight: minHeight), decoration: BoxDecoration( @@ -31,12 +31,12 @@ class AssetDetails extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const DragHandle(), - const DateTimeDetails(), - const PeopleDetails(), - const LocationDetails(), - const TechnicalDetails(), - const RatingDetails(), - const AppearsInDetails(), + DateTimeDetails(asset: asset, exifInfo: exifInfo), + PeopleDetails(asset: asset), + LocationDetails(asset: asset, exifInfo: exifInfo), + TechnicalDetails(asset: asset, exifInfo: exifInfo), + RatingDetails(exifInfo: exifInfo), + AppearsInDetails(asset: asset), SizedBox(height: context.padding.bottom + 48), ], ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart index a3d6bdb8ab..fc15503a3f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart @@ -8,27 +8,25 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class AppearsInDetails extends ConsumerWidget { - const AppearsInDetails({super.key}); + final BaseAsset asset; + + const AppearsInDetails({super.key, required this.asset}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null || !asset.hasRemote) return const SizedBox.shrink(); + if (!asset.hasRemote) return const SizedBox.shrink(); - String? remoteAssetId; - if (asset is RemoteAsset) { - remoteAssetId = asset.id; - } else if (asset is LocalAsset) { - remoteAssetId = asset.remoteAssetId; - } + final remoteAssetId = switch (asset) { + RemoteAsset(:final id) => id, + LocalAsset(:final remoteAssetId) => remoteAssetId, + }; if (remoteAssetId == null) return const SizedBox.shrink(); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart index 4872bf9e75..27bac68310 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -18,14 +17,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; const _kSeparator = ' • '; class DateTimeDetails extends ConsumerWidget { - const DateTimeDetails({super.key}); + final BaseAsset asset; + final ExifInfo? exifInfo; + + const DateTimeDetails({super.key, required this.asset, this.exifInfo}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) return const SizedBox.shrink(); - - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final asset = this.asset; + final exifInfo = this.exifInfo; final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); return Column( @@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> @override Widget build(BuildContext context) { - final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - - final currentDescription = currentExifInfo?.description ?? ''; + final currentDescription = widget.exif.description ?? ''; final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( context: context, ); @@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, ), - onTapOutside: (_) => saveDescription(currentExifInfo?.description), + onTapOutside: (_) => saveDescription(widget.exif.description), ), ), ); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart index 0665f4d46c..8c144a83bd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart @@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class LocationDetails extends ConsumerStatefulWidget { - const LocationDetails({super.key}); + final BaseAsset asset; + final ExifInfo? exifInfo; + + const LocationDetails({super.key, required this.asset, this.exifInfo}); @override ConsumerState createState() => _LocationDetailsState(); @@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState { _mapController = controller; } - void _onExifChanged(AsyncValue? previous, AsyncValue current) { - final currentExif = current.valueOrNull; - if (currentExif != null && currentExif.hasCoordinates) { - _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!))); - } - } - @override - void initState() { - super.initState(); - ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true); + void didUpdateWidget(LocationDetails oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.exifInfo != oldWidget.exifInfo) { + final exif = widget.exifInfo; + if (exif != null && exif.hasCoordinates) { + _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exif.latitude!, exif.longitude!))); + } + } } void editLocation() async { @@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState { @override Widget build(BuildContext context) { - final asset = ref.watch(currentAssetNotifier); - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final asset = widget.asset; + final exifInfo = widget.exifInfo; final hasCoordinates = exifInfo?.hasCoordinates ?? false; // Guard local assets diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 5074c63c9c..6c6f4a002c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; @@ -15,17 +14,14 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/people.utils.dart'; -class PeopleDetails extends ConsumerStatefulWidget { - const PeopleDetails({super.key}); +class PeopleDetails extends ConsumerWidget { + final BaseAsset asset; + + const PeopleDetails({super.key, required this.asset}); @override - ConsumerState createState() => _PeopleDetailsState(); -} - -class _PeopleDetailsState extends ConsumerState { - @override - Widget build(BuildContext context) { - final asset = ref.watch(currentAssetNotifier); + Widget build(BuildContext context, WidgetRef ref) { + final asset = this.asset; if (asset is! RemoteAsset) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart index 982ea67583..fb3a9dd8a8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -1,16 +1,18 @@ 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/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; class RatingDetails extends ConsumerWidget { - const RatingDetails({super.key}); + final ExifInfo? exifInfo; + + const RatingDetails({super.key, this.exifInfo}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -20,8 +22,6 @@ class RatingDetails extends ConsumerWidget { if (!isRatingEnabled) return const SizedBox.shrink(); - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - return Padding( padding: const EdgeInsets.only(left: 16.0, top: 16.0), child: Column( diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart index d79362b559..52d00828f1 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -6,21 +6,20 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; const _kSeparator = ' • '; class TechnicalDetails extends ConsumerWidget { - const TechnicalDetails({super.key}); + final BaseAsset asset; + final ExifInfo? exifInfo; + + const TechnicalDetails({super.key, required this.asset, this.exifInfo}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) return const SizedBox.shrink(); - - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final exifInfo = this.exifInfo; final cameraTitle = _getCameraInfoTitle(exifInfo); final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 77fe8634a9..5da8227ef0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -12,16 +12,16 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.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/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -52,7 +52,6 @@ class _AssetPageState extends ConsumerState { final _scrollController = ScrollController(); late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); - double _snapOffset = 0.0; DragStartDetails? _dragStart; @@ -246,14 +245,16 @@ class _AssetPageState extends ConsumerState { ref.read(isPlayingMotionVideoProvider.notifier).playing = true; void _onScaleStateChanged(PhotoViewScaleState scaleState) { - _isZoomed = switch (scaleState) { - PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true, - _ => false, - }; + _isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering; _viewer.setZoomed(_isZoomed); if (scaleState != PhotoViewScaleState.initial) { if (_dragStart == null) _viewer.setControls(false); + + final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag; + if (heroTag != null) { + ref.read(videoPlayerProvider(heroTag).notifier).pause(); + } return; } @@ -288,22 +289,20 @@ class _AssetPageState extends ConsumerState { _listenForScaleBoundaries(controller); } - Widget _buildPhotoView( - BaseAsset displayAsset, - BaseAsset asset, { - required bool isCurrentPage, - required bool showingDetails, + Widget _buildPhotoView({ + required BaseAsset asset, + required PhotoViewHeroAttributes? heroAttributes, + required bool isCurrent, required bool isPlayingMotionVideo, required BoxDecoration backgroundDecoration, }) { - final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null; + final size = context.sizeData; - if (displayAsset.isImage && !isPlayingMotionVideo) { - final size = context.sizeData; + if (asset.isImage && !isPlayingMotionVideo) { return PhotoView( - key: Key(displayAsset.heroTag), + key: Key(asset.heroTag), index: widget.index, - imageProvider: getFullImageProvider(displayAsset, size: size), + imageProvider: getFullImageProvider(asset, size: size), heroAttributes: heroAttributes, loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), backgroundDecoration: backgroundDecoration, @@ -311,7 +310,7 @@ class _AssetPageState extends ConsumerState { filterQuality: FilterQuality.high, tightMode: true, enablePanAlways: true, - disableScaleGestures: showingDetails, + disableScaleGestures: _showingDetails, scaleStateChangedCallback: _onScaleStateChanged, onPageBuild: _onPageBuild, onDragStart: _onDragStart, @@ -319,45 +318,42 @@ class _AssetPageState extends ConsumerState { onDragEnd: _onDragEnd, onDragCancel: _onDragCancel, onTapUp: _onTapUp, - onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null, + onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, errorBuilder: (_, __, ___) => SizedBox( width: size.width, height: size.height, - child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain), + child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), ), ); } - final Size childSize; - if (displayAsset.width != null && displayAsset.height != null) { - final r = displayAsset.width! / displayAsset.height!; - final w = math.min(context.width, context.height * r); - childSize = Size(w, w / r); - } else { - childSize = Size(context.height, context.height); - } - return PhotoView.customChild( - key: Key(displayAsset.heroTag), - childSize: childSize, - filterQuality: FilterQuality.low, + key: Key(asset.heroTag), + childSize: asset.width != null && asset.height != null + ? Size(asset.width!.toDouble(), asset.height!.toDouble()) + : null, onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, onDragCancel: _onDragCancel, onTapUp: _onTapUp, - heroAttributes: heroAttributes, - basePosition: Alignment.center, - disableScaleGestures: showingDetails, scaleStateChangedCallback: _onScaleStateChanged, + heroAttributes: heroAttributes, + filterQuality: FilterQuality.high, + basePosition: Alignment.center, + disableScaleGestures: _showingDetails, + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + tightMode: true, onPageBuild: _onPageBuild, enablePanAlways: true, backgroundDecoration: backgroundDecoration, child: NativeVideoViewer( - key: _NativeVideoViewerKey(displayAsset.heroTag), - asset: displayAsset, + key: _NativeVideoViewerKey(asset.heroTag), + asset: asset, + isCurrent: isCurrent, image: Image( - image: getFullImageProvider(displayAsset, size: childSize), + image: getFullImageProvider(asset, size: size), fit: BoxFit.contain, alignment: Alignment.center, ), @@ -383,6 +379,8 @@ class _AssetPageState extends ConsumerState { displayAsset = stackChildren.elementAt(stackIndex); } + final isCurrent = currentHeroTag == displayAsset.heroTag; + final viewportWidth = MediaQuery.widthOf(context); final viewportHeight = MediaQuery.heightOf(context); final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset); @@ -396,65 +394,63 @@ class _AssetPageState extends ConsumerState { _proxyScrollController.snapPosition.snapOffset = _snapOffset; } - return ProviderScope( - overrides: [ - currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)), - currentAssetExifProvider.overrideWith((ref) { - final a = ref.watch(currentAssetNotifier); - if (a == null) return Future.value(null); - return ref.watch(assetServiceProvider).getExif(a); - }), - ], - child: Stack( - children: [ - Offstage( - child: SingleChildScrollView( - controller: _proxyScrollController, - physics: const SnapScrollPhysics(), - child: const SizedBox.shrink(), - ), + return Stack( + children: [ + Offstage( + child: SingleChildScrollView( + controller: _proxyScrollController, + physics: const SnapScrollPhysics(), + child: const SizedBox.shrink(), ), - SingleChildScrollView( - controller: _scrollController, - physics: const NeverScrollableScrollPhysics(), - child: Stack( - children: [ - SizedBox( - width: viewportWidth, - height: viewportHeight, - child: _buildPhotoView( - displayAsset, - asset, - isCurrentPage: currentHeroTag == asset.heroTag, - showingDetails: _showingDetails, - isPlayingMotionVideo: isPlayingMotionVideo, - backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), - ), + ), + SingleChildScrollView( + controller: _scrollController, + physics: const NeverScrollableScrollPhysics(), + child: Stack( + children: [ + SizedBox( + width: viewportWidth, + height: viewportHeight, + child: _buildPhotoView( + asset: displayAsset, + heroAttributes: isCurrent + ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') + : null, + isCurrent: isCurrent, + isPlayingMotionVideo: isPlayingMotionVideo, + backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), ), - IgnorePointer( - ignoring: !_showingDetails, - child: Column( - children: [ - SizedBox(height: detailsOffset), - GestureDetector( - onVerticalDragStart: _beginDrag, - onVerticalDragUpdate: _updateDrag, - onVerticalDragEnd: _endDrag, - onVerticalDragCancel: _onDragCancel, - child: AnimatedOpacity( - opacity: _showingDetails ? 1.0 : 0.0, - duration: Durations.short2, - child: AssetDetails(minHeight: viewportHeight - snapTarget), - ), + ), + IgnorePointer( + ignoring: !_showingDetails, + child: Column( + children: [ + SizedBox(height: detailsOffset), + GestureDetector( + onVerticalDragStart: _beginDrag, + onVerticalDragUpdate: _updateDrag, + onVerticalDragEnd: _endDrag, + onVerticalDragCancel: _onDragCancel, + child: AnimatedOpacity( + opacity: _showingDetails ? 1.0 : 0.0, + duration: Durations.short2, + child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), - ], - ), + ), + if (stackChildren != null && stackChildren.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: context.padding.bottom, + child: AssetStackRow(stack: stackChildren), + ), + ], ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 2835342b85..213dc92ef3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -1,53 +1,42 @@ 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/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetStackRow extends ConsumerWidget { - const AssetStackRow({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset)); - if (asset == null) { - return const SizedBox.shrink(); - } - - final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull; - if (stackChildren == null || stackChildren.isEmpty) { - return const SizedBox.shrink(); - } - - final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); - if (showingDetails) { - return const SizedBox.shrink(); - } - return _StackList(stack: stackChildren); - } -} - -class _StackList extends ConsumerWidget { final List stack; - const _StackList({required this.stack}); + const AssetStackRow({super.key, required this.stack}); @override Widget build(BuildContext context, WidgetRef ref) { - return Center( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Padding( - padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 5.0, - children: List.generate(stack.length, (i) { - final asset = stack[i]; - return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i); - }), + if (stack.isEmpty) { + return const SizedBox.shrink(); + } + + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); + + return IgnorePointer( + ignoring: opacity < 1.0, + child: AnimatedOpacity( + opacity: opacity, + duration: Durations.short2, + child: Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 5.0, + children: List.generate(stack.length, (i) { + final asset = stack[i]; + return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i); + }), + ), + ), ), ), ), @@ -67,8 +56,9 @@ class _StackItem extends ConsumerStatefulWidget { class _StackItemState extends ConsumerState<_StackItem> { void _onTap() { - ref.read(currentAssetNotifier.notifier).setAsset(widget.asset); - ref.read(assetViewerProvider.notifier).setStackIndex(widget.index); + final notifier = ref.read(assetViewerProvider.notifier); + notifier.setAsset(widget.asset); + notifier.setStackIndex(widget.index); } @override diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index b353c6d80f..903105406c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -17,13 +17,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -72,15 +69,7 @@ class AssetViewer extends ConsumerStatefulWidget { } static void _setAsset(WidgetRef ref, BaseAsset asset) { - // Always holds the current asset from the timeline ref.read(assetViewerProvider.notifier).setAsset(asset); - // The currentAssetNotifier actually holds the current asset that is displayed - // which could be stack children as well - ref.read(currentAssetNotifier.notifier).setAsset(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - ref.read(videoPlayerControlsProvider.notifier).pause(); - } // Hide controls by default for videos if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false); } @@ -91,6 +80,8 @@ class _AssetViewerState extends ConsumerState { late final _pageController = PageController(initialPage: widget.initialIndex); late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); + late int _currentPage = widget.initialIndex; + StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; @@ -102,7 +93,9 @@ class _AssetViewerState extends ConsumerState { final target = page + direction; final maxPage = ref.read(timelineServiceProvider).totalAssets - 1; if (target >= 0 && target <= maxPage) { + _currentPage = target; _pageController.jumpToPage(target); + _onAssetChanged(target); } } @@ -110,7 +103,7 @@ class _AssetViewerState extends ConsumerState { void initState() { super.initState(); - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; assert(asset != null, "Current asset should not be null when opening the AssetViewer"); if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); @@ -134,6 +127,26 @@ class _AssetViewerState extends ConsumerState { super.dispose(); } + // The normal onPageChange callback listens to OnScrollUpdate events, and will + // round the current page and update whenever that value changes. In practise, + // this means that the page will change when swiped half way, and may flip + // whilst dragging. + // + // Changing the page at the end of a scroll should be more robust, and allow + // the page to be dragged more than half way whilst keeping the current video + // playing, and preventing the video on the next page from becoming ready + // unnecessarily. + bool _onScrollEnd(ScrollEndNotification notification) { + if (notification.depth != 0) return false; + + final page = _pageController.page?.round(); + if (page != null && page != _currentPage) { + _currentPage = page; + _onAssetChanged(page); + } + return false; + } + void _onAssetInit(Duration timeStamp) { _preloader.preload(widget.initialIndex, context.sizeData); _handleCasting(); @@ -153,7 +166,7 @@ class _AssetViewerState extends ConsumerState { void _handleCasting() { if (!ref.read(castProvider).isCasting) return; - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; if (asset == null) return; if (asset is RemoteAsset) { @@ -195,17 +208,19 @@ class _AssetViewerState extends ConsumerState { } var index = _pageController.page?.round() ?? 0; - final currentAsset = ref.read(currentAssetNotifier); + final currentAsset = ref.read(assetViewerProvider).currentAsset; if (currentAsset != null) { final newIndex = timelineService.getIndex(currentAsset.heroTag); if (newIndex != null && newIndex != index) { index = newIndex; + _currentPage = index; _pageController.jumpToPage(index); } } if (index >= totalAssets) { index = totalAssets - 1; + _currentPage = index; _pageController.jumpToPage(index); } @@ -221,7 +236,7 @@ class _AssetViewerState extends ConsumerState { final newAsset = await timelineService.getAssetAsync(index); if (newAsset == null) return; - final currentAsset = ref.read(currentAssetNotifier); + final currentAsset = ref.read(assetViewerProvider).currentAsset; // Do not reload if the asset has not changed if (newAsset.heroTag == currentAsset?.heroTag) return; @@ -258,25 +273,26 @@ class _AssetViewerState extends ConsumerState { _setSystemUIMode(controls, details); }); - return PopScope( - onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(), - child: Scaffold( - backgroundColor: backgroundColor, - appBar: const ViewerTopAppBar(), - extendBody: true, - extendBodyBehindAppBar: true, - floatingActionButton: IgnorePointer( - ignoring: !showingControls, - child: AnimatedOpacity( - opacity: showingControls ? 1.0 : 0.0, - duration: Durations.short2, - child: const DownloadStatusFloatingButton(), - ), + return Scaffold( + backgroundColor: backgroundColor, + resizeToAvoidBottomInset: false, + appBar: const ViewerTopAppBar(), + extendBody: true, + extendBodyBehindAppBar: true, + floatingActionButton: IgnorePointer( + ignoring: !showingControls, + child: AnimatedOpacity( + opacity: showingControls ? 1.0 : 0.0, + duration: Durations.short2, + child: const DownloadStatusFloatingButton(), ), - bottomNavigationBar: const ViewerBottomAppBar(), - body: Stack( - children: [ - PhotoViewGestureDetectorScope( + ), + bottomNavigationBar: const ViewerBottomAppBar(), + body: Stack( + children: [ + NotificationListener( + onNotification: _onScrollEnd, + child: PhotoViewGestureDetectorScope( axis: Axis.horizontal, child: PageView.builder( controller: _pageController, @@ -286,21 +302,20 @@ class _AssetViewerState extends ConsumerState { ? const FastScrollPhysics() : const FastClampingScrollPhysics(), itemCount: ref.read(timelineServiceProvider).totalAssets, - onPageChanged: (index) => _onAssetChanged(index), itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), ), ), - if (!CurrentPlatform.isIOS) - IgnorePointer( - child: AnimatedContainer( - duration: Durations.short2, - color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), - height: context.padding.top, - ), + ), + if (!CurrentPlatform.isIOS) + IgnorePointer( + child: AnimatedContainer( + duration: Durations.short2, + color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), + height: context.padding.top, ), - ], - ), + ), + ], ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 93006ab978..113c55932f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -9,8 +9,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } @@ -65,9 +64,9 @@ class ViewerBottomBar extends ConsumerWidget { color: Colors.black.withAlpha(125), padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), child: Column( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, children: [ - if (asset.isVideo) const VideoControls(), + if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), if (!isReadonlyModeEnabled) Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), ], 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 01970422a8..ecfe0b3ddc 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -1,8 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; @@ -11,420 +9,225 @@ import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/debounce.dart'; -import 'package:immich_mobile/utils/hooks/interval_hook.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; -bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) { - if (asset is RemoteAsset) { - return switch (currentAsset) { - RemoteAsset remoteAsset => remoteAsset.id == asset.id, - LocalAsset localAsset => localAsset.remoteId == asset.id, - _ => false, - }; - } else if (asset is LocalAsset) { - return switch (currentAsset) { - RemoteAsset remoteAsset => remoteAsset.localId == asset.id, - LocalAsset localAsset => localAsset.id == asset.id, - _ => false, - }; - } - return false; -} - -class NativeVideoViewer extends HookConsumerWidget { - static final log = Logger('NativeVideoViewer'); +class NativeVideoViewer extends ConsumerStatefulWidget { final BaseAsset asset; - final int playbackDelayFactor; + final bool isCurrent; + final bool showControls; final Widget image; - const NativeVideoViewer({super.key, required this.asset, required this.image, this.playbackDelayFactor = 1}); + const NativeVideoViewer({ + super.key, + required this.asset, + required this.image, + this.isCurrent = false, + this.showControls = true, + }); @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useState(null); - final lastVideoPosition = useRef(-1); - final isBuffering = useRef(false); + ConsumerState createState() => _NativeVideoViewerState(); +} - // Used to track whether the video should play when the app - // is brought back to the foreground - final shouldPlayOnForeground = useRef(true); +class _NativeVideoViewerState extends ConsumerState with WidgetsBindingObserver { + static final _log = Logger('NativeVideoViewer'); - // When a video is opened through the timeline, `isCurrent` will immediately be true. - // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. - // If the swipe is completed, `isCurrent` will be true for video B after a delay. - // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. - final currentAsset = useState(ref.read(currentAssetNotifier)); - final isCurrent = _isCurrentAsset(asset, currentAsset.value); + NativeVideoPlayerController? _controller; + late final Future _videoSource; + Timer? _loadTimer; + bool _isVideoReady = false; + bool _shouldPlayOnForeground = true; - // Used to show the placeholder during hero animations for remote videos to avoid a stutter - final isVisible = useState(Platform.isIOS && asset.hasLocal); + VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier); - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); - - Future createSource() async { - if (!context.mounted) { - return null; - } - - final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset; - if (!context.mounted) { - return null; - } - - try { - if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { - final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; - final file = await StorageRepository().getFileForAsset(id); - if (!context.mounted) { - return null; - } - - if (file == null) { - throw Exception('No file found for the video'); - } - - // Pass a file:// URI so Android's Uri.parse doesn't - // interpret characters like '#' as fragment identifiers. - final source = await VideoSource.init( - path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, - type: VideoSourceType.file, - ); - return source; - } - - final remoteId = (videoAsset as RemoteAsset).id; - - // Use a network URL for the video player controller - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final isOriginalVideo = ref.read(settingsProvider).get(Setting.loadOriginalVideo); - final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; - final String videoUrl = videoAsset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' - : '$serverEndpoint/assets/$remoteId/$postfixUrl'; - - final source = await VideoSource.init( - path: videoUrl, - type: VideoSourceType.network, - headers: ApiService.getRequestHeaders(), - ); - return source; - } catch (error) { - log.severe('Error creating video source for asset ${videoAsset.name}: $error'); - return null; - } - } - - final videoSource = useMemoized>(() => createSource()); - final aspectRatio = useState(null); - useMemoized(() async { - if (!context.mounted || aspectRatio.value != null) { - return null; - } - - try { - aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset); - } catch (error) { - log.severe('Error getting aspect ratio for asset ${asset.name}: $error'); - } - }, [asset.heroTag]); - - void checkIfBuffering() { - if (!context.mounted) { - return; - } - - final videoPlayback = ref.read(videoPlaybackValueProvider); - if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) && - videoPlayback.state != VideoPlaybackState.buffering) { - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith( - state: VideoPlaybackState.buffering, - ); - } - } - - // Timer to mark videos as buffering if the position does not change - useInterval(const Duration(seconds: 5), checkIfBuffering); - - // When the position changes, seek to the position - // Debounce the seek to avoid seeking too often - // But also don't delay the seek too much to maintain visual feedback - final seekDebouncer = useDebouncer( - interval: const Duration(milliseconds: 100), - maxWaitTime: const Duration(milliseconds: 200), - ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { - final playerController = controller.value; - if (playerController == null) { - return; - } - - final playbackInfo = playerController.playbackInfo; - if (playbackInfo == null) { - return; - } - - final oldSeek = oldControls?.position.inMilliseconds; - final newSeek = newControls.position.inMilliseconds; - if (oldSeek != newSeek || newControls.restarted) { - seekDebouncer.run(() => playerController.seekTo(newSeek)); - } - - if (oldControls?.pause != newControls.pause || newControls.restarted) { - unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); - } - }); - - void onPlaybackReady() async { - final videoController = controller.value; - if (videoController == null || !isCurrent || !context.mounted) { - return; - } - - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - - if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) { - return; - } - - try { - final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); - if (autoPlayVideo) { - await videoController.play(); - } - await videoController.setVolume(0.9); - } catch (error) { - log.severe('Error playing video: $error'); - } - } - - void onPlaybackStatusChanged() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - if (videoPlayback.state == VideoPlaybackState.playing) { - // Sync with the controls playing - WakelockPlus.enable(); - } else { - // Sync with the controls pause - WakelockPlus.disable(); - } - - ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state; - } - - void onPlaybackPositionChanged() { - // When seeking, these events sometimes move the slider to an older position - if (seekDebouncer.isActive) { - return; - } - - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final playbackInfo = videoController.playbackInfo; - if (playbackInfo == null) { - return; - } - - ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); - - // Check if the video is buffering - if (playbackInfo.status == PlaybackStatus.playing) { - isBuffering.value = lastVideoPosition.value == playbackInfo.position; - lastVideoPosition.value = playbackInfo.position; - } else { - isBuffering.value = false; - lastVideoPosition.value = -1; - } - } - - void onPlaybackEnded() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - if (videoController.playbackInfo?.status == PlaybackStatus.stopped) { - ref.read(isPlayingMotionVideoProvider.notifier).playing = false; - } - } - - void removeListeners(NativeVideoPlayerController controller) { - controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged); - controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged); - controller.onPlaybackReady.removeListener(onPlaybackReady); - controller.onPlaybackEnded.removeListener(onPlaybackEnded); - } - - void initController(NativeVideoPlayerController nc) async { - if (controller.value != null || !context.mounted) { - return; - } - ref.read(videoPlayerControlsProvider.notifier).reset(); - ref.read(videoPlaybackValueProvider.notifier).reset(); - - final source = await videoSource; - if (source == null || !context.mounted) { - return; - } - - nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); - nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); - nc.onPlaybackReady.addListener(onPlaybackReady); - nc.onPlaybackEnded.addListener(onPlaybackEnded); - - unawaited( - nc.loadVideoSource(source).catchError((error) { - log.severe('Error loading video source: $error'); - }), - ); - final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); - unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo)); - - controller.value = nc; - Timer(const Duration(milliseconds: 200), checkIfBuffering); - } - - ref.listen(currentAssetNotifier, (_, value) { - final playerController = controller.value; - if (playerController != null && value != asset) { - removeListeners(playerController); - } - - if (value != null) { - isVisible.value = _isCurrentAsset(value, asset); - } - final curAsset = currentAsset.value; - if (curAsset == asset) { - return; - } - - final imageToVideo = curAsset != null && !curAsset.isVideo; - - // No need to delay video playback when swiping from an image to a video - if (imageToVideo && Platform.isIOS) { - currentAsset.value = value; - onPlaybackReady(); - return; - } - - // Delay the video playback to avoid a stutter in the swipe animation - // Note, in some circumstances a longer delay is needed (eg: memories), - // the playbackDelayFactor can be used for this - // This delay seems like a hacky way to resolve underlying bugs in video - // playback, but other resolutions failed thus far - Timer( - Platform.isIOS - ? Duration(milliseconds: 300 * playbackDelayFactor) - : imageToVideo - ? Duration(milliseconds: 200 * playbackDelayFactor) - : Duration(milliseconds: 400 * playbackDelayFactor), - () { - if (!context.mounted) { - return; - } - - currentAsset.value = value; - if (currentAsset.value == asset) { - onPlaybackReady(); - } - }, - ); - }); - - useEffect(() { - // If opening a remote video from a hero animation, delay visibility to avoid a stutter - final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true); - - return () { - timer?.cancel(); - final playerController = controller.value; - if (playerController == null) { - return; - } - removeListeners(playerController); - playerController.stop().catchError((error) { - log.fine('Error stopping video: $error'); - }); - - WakelockPlus.disable(); - }; - }, const []); - - useOnAppLifecycleStateChange((_, state) async { - if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) { - await controller.value?.play(); - } else if (state == AppLifecycleState.paused) { - final videoPlaying = await controller.value?.isPlaying(); - if (videoPlaying ?? true) { - shouldPlayOnForeground.value = true; - await controller.value?.pause(); - } else { - shouldPlayOnForeground.value = false; - } - } - }); - - return Stack( - children: [ - // This remains under the video to avoid flickering - // For motion videos, this is the image portion of the asset - Center(child: image), - if (aspectRatio.value != null && !isCasting) - Visibility.maintain( - visible: isVisible.value, - child: NativeVideoPlayerView(onViewReady: initController), - ), - const Center(child: VideoViewerControls()), - ], - ); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _videoSource = _createSource(); } - Future _onPauseChange( - BuildContext context, - NativeVideoPlayerController controller, - Debouncer seekDebouncer, - bool isPaused, - ) async { - if (!context.mounted) { + @override + void didUpdateWidget(NativeVideoViewer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return; + + if (!widget.isCurrent) { + _loadTimer?.cancel(); + _notifier.pause(); return; } - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } + // Prevent unnecessary loading when swiping between assets. + _loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo); + } - try { - if (isPaused) { - await controller.pause(); - } else { - await controller.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _loadTimer?.cancel(); + _removeListeners(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + switch (state) { + case AppLifecycleState.resumed: + if (_shouldPlayOnForeground) await _notifier.play(); + case AppLifecycleState.paused: + _shouldPlayOnForeground = await _controller?.isPlaying() ?? true; + if (_shouldPlayOnForeground) await _notifier.pause(); + default: } } + + Future _createSource() async { + if (!mounted) return null; + + final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset; + if (!mounted) return null; + + try { + if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { + final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; + final file = await StorageRepository().getFileForAsset(id); + if (!mounted) return null; + + if (file == null) { + throw Exception('No file found for the video'); + } + + // Pass a file:// URI so Android's Uri.parse doesn't + // interpret characters like '#' as fragment identifiers. + return VideoSource.init( + path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, + type: VideoSourceType.file, + ); + } + + final remoteId = (videoAsset as RemoteAsset).id; + + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final isOriginalVideo = ref.read(settingsProvider).get(Setting.loadOriginalVideo); + final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; + final String videoUrl = videoAsset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' + : '$serverEndpoint/assets/$remoteId/$postfixUrl'; + + return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders()); + } catch (error) { + _log.severe('Error creating video source for asset ${videoAsset.name}: $error'); + return null; + } + } + + void _onPlaybackReady() async { + if (!mounted || !widget.isCurrent) return; + + _notifier.onNativePlaybackReady(); + + // onPlaybackReady may be called multiple times, usually when more data + // loads. If this is not the first time that the player has become ready, we + // should not autoplay. + if (_isVideoReady) return; + + setState(() => _isVideoReady = true); + + if (ref.read(assetViewerProvider).showingDetails) return; + + final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); + if (autoPlayVideo) await _notifier.play(); + } + + void _onPlaybackEnded() { + if (!mounted) return; + + _notifier.onNativePlaybackEnded(); + + if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; + } + } + + void _onPlaybackPositionChanged() { + if (!mounted) return; + _notifier.onNativePositionChanged(); + } + + void _onPlaybackStatusChanged() { + if (!mounted) return; + _notifier.onNativeStatusChanged(); + } + + void _removeListeners() { + _controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged); + _controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged); + _controller?.onPlaybackReady.removeListener(_onPlaybackReady); + _controller?.onPlaybackEnded.removeListener(_onPlaybackEnded); + } + + void _loadVideo() async { + final nc = _controller; + if (nc == null || nc.videoSource != null || !mounted) return; + + final source = await _videoSource; + if (source == null || !mounted) return; + + unawaited( + nc.loadVideoSource(source).catchError((error) { + _log.severe('Error loading video source: $error'); + }), + ); + final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); + await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); + await _notifier.setVolume(1); + } + + void _initController(NativeVideoPlayerController nc) { + if (_controller != null || !mounted) return; + + _notifier.attachController(nc); + + nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged); + nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged); + nc.onPlaybackReady.addListener(_onPlaybackReady); + nc.onPlaybackEnded.addListener(_onPlaybackEnded); + + _controller = nc; + + if (widget.isCurrent) _loadVideo(); + } + + @override + Widget build(BuildContext context) { + // Prevent the provider from being disposed whilst the widget is alive. + ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {}); + + final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + + return Stack( + children: [ + Center(child: widget.image), + if (!isCasting) + Visibility.maintain( + visible: _isVideoReady, + child: NativeVideoPlayerView(onViewReady: _initController), + ), + if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)), + ], + ); + } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart index 28cfe5e73c..e079f666ec 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart @@ -1,29 +1,26 @@ 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/models/cast/cast_manager_state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.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/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; class VideoViewerControls extends HookConsumerWidget { + final BaseAsset asset; final Duration hideTimerDuration; - const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)}); + const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)}); @override Widget build(BuildContext context, WidgetRef ref) { - final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo)); - bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); - if (showingDetails) { - showControls = false; - } - final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + final videoPlayerName = asset.heroTag; + final assetIsVideo = asset.isVideo; + final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails)); + final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status)); final cast = ref.watch(castProvider); @@ -32,14 +29,14 @@ class VideoViewerControls extends HookConsumerWidget { if (!context.mounted) { return; } - final state = ref.read(videoPlaybackValueProvider).state; + final status = ref.read(videoPlayerProvider(videoPlayerName)).status; // Do not hide on paused - if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) { + if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) { ref.read(assetViewerProvider.notifier).setControls(false); } }); - final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting; + final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -47,9 +44,11 @@ class VideoViewerControls extends HookConsumerWidget { ref.read(assetViewerProvider.notifier).setControls(true); } - // When we change position, show or hide timer - ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { - showControlsAndStartHideTimer(); + // When playback starts, reset the hide timer + ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) { + if (next == VideoPlaybackStatus.playing) { + hideTimer.reset(); + } }); /// Toggles between playing and pausing depending on the state of the video @@ -57,34 +56,30 @@ class VideoViewerControls extends HookConsumerWidget { showControlsAndStartHideTimer(); if (cast.isCasting) { - if (cast.castState == CastState.playing) { - ref.read(castProvider.notifier).pause(); - } else if (cast.castState == CastState.paused) { - ref.read(castProvider.notifier).play(); - } else if (cast.castState == CastState.idle) { - // resend the play command since its finished - final asset = ref.read(currentAssetNotifier); - if (asset == null) { - return; - } - // ref.read(castProvider.notifier).loadMedia(asset, true); + switch (cast.castState) { + case CastState.playing: + ref.read(castProvider.notifier).pause(); + case CastState.paused: + ref.read(castProvider.notifier).play(); + default: } return; } - if (state == VideoPlaybackState.playing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); - } else if (state == VideoPlaybackState.completed) { - ref.read(videoPlayerControlsProvider.notifier).restart(); - } else { - ref.read(videoPlayerControlsProvider.notifier).play(); + final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier); + switch (status) { + case VideoPlaybackStatus.playing: + notifier.pause(); + case VideoPlaybackStatus.completed: + notifier.restart(); + default: + notifier.play(); } } void toggleControlsVisibility() { - if (showBuffering) { - return; - } + if (showBuffering) return; + if (showControls) { ref.read(assetViewerProvider.notifier).setControls(false); } else { @@ -105,9 +100,9 @@ class VideoViewerControls extends HookConsumerWidget { CenterPlayButton( backgroundColor: Colors.black54, iconColor: Colors.white, - isFinished: state == VideoPlaybackState.completed, + isFinished: status == VideoPlaybackStatus.completed, isPlaying: - state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), + status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing), show: assetIsVideo && showControls, onPressed: togglePlay, ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart index aa3b8bb93f..1c0b600843 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; class ViewerBottomAppBar extends ConsumerWidget { @@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - - if (!showControls) { - opacity = 0.0; - } + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); return IgnorePointer( ignoring: opacity < 1.0, - child: AnimatedOpacity( - opacity: opacity, - duration: Durations.short2, - child: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [AssetStackRow(), ViewerBottomBar()], - ), - ), + child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const ViewerBottomBar()), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index fb25e9e1cb..78b2e50da5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -21,7 +21,7 @@ class ViewerKebabMenu extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 4b748abc27..4ba4152a8d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -8,10 +8,9 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -22,7 +21,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } @@ -35,16 +34,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); - double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) { ref.watch(albumActivityProvider(album.id, asset.id)); } - if (!showControls) { - opacity = 0.0; - } + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); final originalTheme = context.themeData; diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index d6485ae7b6..3593fc75e8 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; diff --git a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart index 7758944d37..3df9c8074e 100644 --- a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart @@ -13,12 +13,14 @@ class DriftMemoryCard extends StatelessWidget { final RemoteAsset asset; final String title; final bool showTitle; + final bool isCurrent; final Function()? onVideoEnded; const DriftMemoryCard({ required this.asset, required this.title, required this.showTitle, + this.isCurrent = false, this.onVideoEnded, super.key, }); @@ -37,32 +39,35 @@ class DriftMemoryCard extends StatelessWidget { SizedBox.expand(child: _BlurredBackdrop(asset: asset)), LayoutBuilder( builder: (context, constraints) { + final r = asset.width != null && asset.height != null + ? asset.width! / asset.height! + : constraints.maxWidth / constraints.maxHeight; + // Determine the fit using the aspect ratio BoxFit fit = BoxFit.contain; if (asset.width != null && asset.height != null) { - final aspectRatio = asset.width! / asset.height!; final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight; // Look for a 25% difference in either direction - if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) { + if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) { // Cover to look nice if we have nearly the same aspect ratio fit = BoxFit.cover; } } - if (asset.isImage) { - return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity)); - } else { - return SizedBox( - width: context.width, - height: context.height, + if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity)); + + return Center( + child: AspectRatio( + aspectRatio: r, child: NativeVideoViewer( key: ValueKey(asset.id), asset: asset, - playbackDelayFactor: 2, - image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain), + isCurrent: isCurrent, + showControls: false, + image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain), ), - ); - } + ), + ); }, ), if (showTitle) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart similarity index 79% rename from mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart rename to mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index dc510d6017..785dfd1e4c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -1,5 +1,7 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; class AssetViewerState { @@ -68,6 +70,12 @@ class AssetViewerState { class AssetViewerStateNotifier extends Notifier { @override AssetViewerState build() { + ref.listen(_watchedCurrentAssetProvider, (_, next) { + final updated = next.valueOrNull; + if (updated != null) { + state = state.copyWith(currentAsset: updated); + } + }); return const AssetViewerState(); } @@ -75,10 +83,8 @@ class AssetViewerStateNotifier extends Notifier { state = const AssetViewerState(); } - void setAsset(BaseAsset? asset) { - if (asset == state.currentAsset) { - return; - } + void setAsset(BaseAsset asset) { + if (asset == state.currentAsset) return; state = state.copyWith(currentAsset: asset, stackIndex: 0); } @@ -95,7 +101,10 @@ class AssetViewerStateNotifier extends Notifier { } state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); if (showing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); + final heroTag = state.currentAsset?.heroTag; + if (heroTag != null) { + ref.read(videoPlayerProvider(heroTag).notifier).pause(); + } } } @@ -126,3 +135,10 @@ class AssetViewerStateNotifier extends Notifier { } final assetViewerProvider = NotifierProvider(AssetViewerStateNotifier.new); + +final _watchedCurrentAssetProvider = StreamProvider((ref) { + ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); + final asset = ref.read(assetViewerProvider).currentAsset; + if (asset == null) return const Stream.empty(); + return ref.read(assetServiceProvider).watchAsset(asset); +}); diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart deleted file mode 100644 index 44740268db..0000000000 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; - -class VideoPlaybackControls { - const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false}); - - final Duration position; - final bool pause; - final bool restarted; -} - -final videoPlayerControlsProvider = StateNotifierProvider((ref) { - return VideoPlayerControls(ref); -}); - -const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false); - -class VideoPlayerControls extends StateNotifier { - VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); - - final Ref ref; - - VideoPlaybackControls get value => state; - - set value(VideoPlaybackControls value) { - state = value; - } - - void reset() { - state = videoPlayerControlsDefault; - } - - Duration get position => state.position; - bool get paused => state.pause; - - set position(Duration value) { - if (state.position == value) { - return; - } - - state = VideoPlaybackControls(position: value, pause: state.pause); - } - - void pause() { - if (state.pause) { - return; - } - - state = VideoPlaybackControls(position: state.position, pause: true); - } - - void play() { - if (!state.pause) { - return; - } - - state = VideoPlaybackControls(position: state.position, pause: false); - } - - void togglePlay() { - state = VideoPlaybackControls(position: state.position, pause: !state.pause); - } - - void restart() { - state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true); - ref.read(videoPlaybackValueProvider.notifier).value = ref - .read(videoPlaybackValueProvider.notifier) - .value - .copyWith(state: VideoPlaybackState.playing, position: Duration.zero); - } -} diff --git a/mobile/lib/providers/asset_viewer/video_player_provider.dart b/mobile/lib/providers/asset_viewer/video_player_provider.dart new file mode 100644 index 0000000000..0ca3bf4f74 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/video_player_provider.dart @@ -0,0 +1,200 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:native_video_player/native_video_player.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +enum VideoPlaybackStatus { paused, playing, buffering, completed } + +class VideoPlayerState { + final Duration position; + final Duration duration; + final VideoPlaybackStatus status; + + const VideoPlayerState({required this.position, required this.duration, required this.status}); + + VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) { + return VideoPlayerState( + position: position ?? this.position, + duration: duration ?? this.duration, + status: status ?? this.status, + ); + } +} + +const _defaultState = VideoPlayerState( + position: Duration.zero, + duration: Duration.zero, + status: VideoPlaybackStatus.paused, +); + +final videoPlayerProvider = StateNotifierProvider.autoDispose.family(( + ref, + name, +) { + return VideoPlayerNotifier(); +}); + +class VideoPlayerNotifier extends StateNotifier { + static final _log = Logger('VideoPlayerNotifier'); + + VideoPlayerNotifier() : super(_defaultState); + + NativeVideoPlayerController? _controller; + Timer? _bufferingTimer; + Timer? _seekTimer; + + void attachController(NativeVideoPlayerController controller) { + _controller = controller; + } + + @override + void dispose() { + _bufferingTimer?.cancel(); + _seekTimer?.cancel(); + WakelockPlus.disable(); + _controller = null; + + super.dispose(); + } + + Future pause() async { + if (_controller == null) return; + + _bufferingTimer?.cancel(); + + try { + await _controller!.pause(); + await _flushSeek(); + } catch (e) { + _log.severe('Error pausing video: $e'); + } + } + + Future play() async { + if (_controller == null) return; + + try { + await _flushSeek(); + await _controller!.play(); + } catch (e) { + _log.severe('Error playing video: $e'); + } + + _startBufferingTimer(); + } + + Future _flushSeek() async { + final timer = _seekTimer; + if (timer == null || !timer.isActive) return; + + timer.cancel(); + await _controller?.seekTo(state.position.inMilliseconds); + } + + void seekTo(Duration position) { + if (_controller == null) return; + + state = state.copyWith(position: position); + + _seekTimer?.cancel(); + _seekTimer = Timer(const Duration(milliseconds: 100), () { + _controller?.seekTo(position.inMilliseconds); + }); + } + + Future restart() async { + seekTo(Duration.zero); + await play(); + } + + Future setVolume(double volume) async { + try { + await _controller?.setVolume(volume); + } catch (e) { + _log.severe('Error setting volume: $e'); + } + } + + Future setLoop(bool loop) async { + try { + await _controller?.setLoop(loop); + } catch (e) { + _log.severe('Error setting loop: $e'); + } + } + + void onNativePlaybackReady() { + if (!mounted) return; + + final playbackInfo = _controller?.playbackInfo; + final videoInfo = _controller?.videoInfo; + + if (playbackInfo == null || videoInfo == null) return; + + state = state.copyWith( + position: Duration(milliseconds: playbackInfo.position), + duration: Duration(milliseconds: videoInfo.duration), + status: _mapStatus(playbackInfo.status), + ); + } + + void onNativePositionChanged() { + if (!mounted || (_seekTimer?.isActive ?? false)) return; + + final playbackInfo = _controller?.playbackInfo; + if (playbackInfo == null) return; + + final position = Duration(milliseconds: playbackInfo.position); + if (state.position == position) return; + + if (state.status == VideoPlaybackStatus.buffering) { + state = state.copyWith(position: position, status: VideoPlaybackStatus.playing); + } else { + state = state.copyWith(position: position); + } + + _startBufferingTimer(); + } + + void onNativeStatusChanged() { + if (!mounted) return; + + final playbackInfo = _controller?.playbackInfo; + if (playbackInfo == null) return; + + final newStatus = _mapStatus(playbackInfo.status); + switch (newStatus) { + case VideoPlaybackStatus.playing: + WakelockPlus.enable(); + _startBufferingTimer(); + default: + onNativePlaybackEnded(); + } + + if (state.status != newStatus) { + state = state.copyWith(status: newStatus); + } + } + + void onNativePlaybackEnded() { + WakelockPlus.disable(); + _bufferingTimer?.cancel(); + } + + void _startBufferingTimer() { + _bufferingTimer?.cancel(); + _bufferingTimer = Timer(const Duration(seconds: 3), () { + if (mounted && state.status == VideoPlaybackStatus.playing) { + state = state.copyWith(status: VideoPlaybackStatus.buffering); + } + }); + } + + static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) { + PlaybackStatus.playing => VideoPlaybackStatus.playing, + PlaybackStatus.paused => VideoPlaybackStatus.paused, + PlaybackStatus.stopped => VideoPlaybackStatus.completed, + }; +} diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart deleted file mode 100644 index 31b0f4656e..0000000000 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:native_video_player/native_video_player.dart'; - -enum VideoPlaybackState { initializing, paused, playing, buffering, completed } - -class VideoPlaybackValue { - /// The current position of the video - final Duration position; - - /// The total duration of the video - final Duration duration; - - /// The current state of the video playback - final VideoPlaybackState state; - - /// The volume of the video - final double volume; - - const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume}); - - factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) { - final playbackInfo = controller.playbackInfo; - final videoInfo = controller.videoInfo; - - if (playbackInfo == null || videoInfo == null) { - return videoPlaybackValueDefault; - } - - final VideoPlaybackState status = switch (playbackInfo.status) { - PlaybackStatus.playing => VideoPlaybackState.playing, - PlaybackStatus.paused => VideoPlaybackState.paused, - PlaybackStatus.stopped => VideoPlaybackState.completed, - }; - - return VideoPlaybackValue( - position: Duration(milliseconds: playbackInfo.position), - duration: Duration(milliseconds: videoInfo.duration), - state: status, - volume: playbackInfo.volume, - ); - } - - VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) { - return VideoPlaybackValue( - position: position ?? this.position, - duration: duration ?? this.duration, - state: state ?? this.state, - volume: volume ?? this.volume, - ); - } -} - -const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - state: VideoPlaybackState.initializing, - volume: 0.0, -); - -final videoPlaybackValueProvider = StateNotifierProvider((ref) { - return VideoPlaybackValueState(ref); -}); - -class VideoPlaybackValueState extends StateNotifier { - VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault); - - final Ref ref; - - VideoPlaybackValue get value => state; - - set value(VideoPlaybackValue value) { - state = value; - } - - set position(Duration value) { - if (state.position == value) return; - state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume); - } - - set status(VideoPlaybackState value) { - if (state.state == value) return; - state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume); - } - - void reset() { - state = videoPlaybackValueDefault; - } -} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index c06bcabf26..cd75af6354 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -8,9 +8,9 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -123,7 +123,7 @@ class ActionNotifier extends Notifier { Set _getAssets(ActionSource source) { return switch (source) { ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, - ActionSource.viewer => switch (ref.read(currentAssetNotifier)) { + ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) { BaseAsset asset => {asset}, null => const {}, }, @@ -307,7 +307,10 @@ class ActionNotifier extends Notifier { // does not update the currentAsset which means // the exif provider will not be refreshed automatically if (source == ActionSource.viewer) { - ref.invalidate(currentAssetExifProvider); + final currentAsset = ref.read(assetViewerProvider).currentAsset; + if (currentAsset != null) { + ref.invalidate(assetExifProvider(currentAsset)); + } } return ActionResult(count: ids.length, success: true); @@ -409,7 +412,6 @@ class ActionNotifier extends Notifier { if (source == ActionSource.viewer) { final updatedParent = await _assetService.getRemoteAsset(assets.first.id); if (updatedParent != null) { - ref.read(currentAssetNotifier.notifier).setAsset(updatedParent); ref.read(assetViewerProvider.notifier).setAsset(updatedParent); } } diff --git a/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart index 5718333759..82ab69b994 100644 --- a/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart @@ -1,52 +1,8 @@ -import 'dart:async'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -final currentAssetNotifier = AutoDisposeNotifierProvider(CurrentAssetNotifier.new); - -class CurrentAssetNotifier extends AutoDisposeNotifier { - KeepAliveLink? _keepAliveLink; - StreamSubscription? _assetSubscription; - - @override - BaseAsset? build() => null; - - void setAsset(BaseAsset asset) { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - state = asset; - _assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) { - if (updatedAsset != null) { - state = updatedAsset; - } - }); - _keepAliveLink = ref.keepAlive(); - } - - void dispose() { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - } -} - -class ScopedAssetNotifier extends CurrentAssetNotifier { - final BaseAsset _asset; - - ScopedAssetNotifier(this._asset); - - @override - BaseAsset? build() { - setAsset(_asset); - return _asset; - } -} - -final currentAssetExifProvider = FutureProvider.autoDispose((ref) { - final currentAsset = ref.watch(currentAssetNotifier); - if (currentAsset == null) { - return null; - } - return ref.watch(assetServiceProvider).getExif(currentAsset); +final assetExifProvider = FutureProvider.autoDispose.family((ref, asset) { + return ref.watch(assetServiceProvider).getExif(asset); }); diff --git a/mobile/lib/utils/hooks/interval_hook.dart b/mobile/lib/utils/hooks/interval_hook.dart deleted file mode 100644 index 907fbad102..0000000000 --- a/mobile/lib/utils/hooks/interval_hook.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter_hooks/flutter_hooks.dart'; - -// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638 -void useInterval(Duration delay, VoidCallback callback) { - final savedCallback = useRef(callback); - savedCallback.value = callback; - - useEffect(() { - final timer = Timer.periodic(delay, (_) => savedCallback.value()); - return timer.cancel; - }, [delay]); -} diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 5707e3678f..22a7deffff 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget { padding: const EdgeInsets.only(top: 40.0), child: Column( children: [ - if (asset.isVideo) const VideoControls(), + if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()), BottomNavigationBar( elevation: 0.0, backgroundColor: Colors.transparent, diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 0e766c77b9..09c0e9d091 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -3,23 +3,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; class CustomVideoPlayerControls extends HookConsumerWidget { + final String videoId; final Duration hideTimerDuration; - const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)}); + const CustomVideoPlayerControls({ + super.key, + required this.videoId, + this.hideTimerDuration = const Duration(seconds: 5), + }); @override Widget build(BuildContext context, WidgetRef ref) { final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo)); final showControls = ref.watch(showControlsProvider); - final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status)); final cast = ref.watch(castProvider); @@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget { if (!context.mounted) { return; } - final state = ref.read(videoPlaybackValueProvider).state; + final s = ref.read(videoPlayerProvider(videoId)).status; // Do not hide on paused - if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) { + if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) { ref.read(showControlsProvider.notifier).show = false; } }); - final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting; + final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ref.read(showControlsProvider.notifier).show = true; } - // When we change position, show or hide timer - ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { - showControlsAndStartHideTimer(); + // When playback starts, reset the hide timer + ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) { + if (next == VideoPlaybackStatus.playing) { + hideTimer.reset(); + } }); /// Toggles between playing and pausing depending on the state of the video @@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget { return; } - if (state == VideoPlaybackState.playing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); - } else if (state == VideoPlaybackState.completed) { - ref.read(videoPlayerControlsProvider.notifier).restart(); + final notifier = ref.read(videoPlayerProvider(videoId).notifier); + if (status == VideoPlaybackStatus.playing) { + notifier.pause(); + } else if (status == VideoPlaybackStatus.completed) { + notifier.restart(); } else { - ref.read(videoPlayerControlsProvider.notifier).play(); + notifier.play(); } } @@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget { child: CenterPlayButton( backgroundColor: Colors.black54, iconColor: Colors.white, - isFinished: state == VideoPlaybackState.completed, + isFinished: status == VideoPlaybackStatus.completed, isPlaying: - state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), + status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing), show: assetIsVideo && showControls, onPressed: togglePlay, ), diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 42f6078478..381388d8d2 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -3,15 +3,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_position.dart'; -/// The video controls for the [videoPlayerControlsProvider] +/// The video controls for the [videoPlayerProvider] class VideoControls extends ConsumerWidget { - const VideoControls({super.key}); + final String videoPlayerName; + + const VideoControls({super.key, required this.videoPlayerName}); @override Widget build(BuildContext context, WidgetRef ref) { final isPortrait = context.orientation == Orientation.portrait; return isPortrait - ? const VideoPosition() - : const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition()); + ? VideoPosition(videoPlayerName: videoPlayerName) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 60.0), + child: VideoPosition(videoPlayerName: videoPlayerName), + ); } } diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index 9d9e2821ad..cbcbdb88e7 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -4,13 +4,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/colors.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; class VideoPosition extends HookConsumerWidget { - const VideoPosition({super.key}); + final String videoPlayerName; + + const VideoPosition({super.key, required this.videoPlayerName}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -18,7 +19,7 @@ class VideoPosition extends HookConsumerWidget { final (position, duration) = isCasting ? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) - : ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration))); + : ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration))); final wasPlaying = useRef(true); return duration == Duration.zero @@ -44,13 +45,13 @@ class VideoPosition extends HookConsumerWidget { activeColor: Colors.white, inactiveColor: whiteOpacity75, onChangeStart: (value) { - final state = ref.read(videoPlaybackValueProvider).state; - wasPlaying.value = state != VideoPlaybackState.paused; - ref.read(videoPlayerControlsProvider.notifier).pause(); + final status = ref.read(videoPlayerProvider(videoPlayerName)).status; + wasPlaying.value = status != VideoPlaybackStatus.paused; + ref.read(videoPlayerProvider(videoPlayerName).notifier).pause(); }, onChangeEnd: (value) { if (wasPlaying.value) { - ref.read(videoPlayerControlsProvider.notifier).play(); + ref.read(videoPlayerProvider(videoPlayerName).notifier).play(); } }, onChanged: (value) { @@ -61,10 +62,7 @@ class VideoPosition extends HookConsumerWidget { return; } - ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration; - - // This immediately updates the slider position without waiting for the video to update - ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration; + ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration); }, ), ), diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 727950fd86..4cba83bea7 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; @@ -34,9 +33,6 @@ class MemoryLane extends HookConsumerWidget { if (memories[memoryIndex].assets.isNotEmpty) { final asset = memories[memoryIndex].assets[0]; ref.read(currentAssetProvider.notifier).set(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } } context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex)); }, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 077544b4f7..28adfc2ab7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1217,10 +1217,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1910,10 +1910,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" thumbhash: dependency: "direct main" description: