From 873f7921da5f0397769f104b6cd1d4a827a88278 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:03:44 -0400 Subject: [PATCH] fix(mobile): ensure current asset is set in asset viewer (#21504) --- mobile/lib/main.dart | 4 +- .../asset_viewer/asset_viewer.page.dart | 63 +++++++++++-------- .../asset_viewer/asset_viewer.state.dart | 15 +++++ .../widgets/timeline/fixed/segment.model.dart | 2 + mobile/lib/services/deep_link.service.dart | 14 +++-- 5 files changed, 64 insertions(+), 34 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 21093df24d..207e522587 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -176,13 +176,13 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve final isColdStart = currentRouteName == null || currentRouteName == SplashScreenRoute.name; if (deepLink.uri.scheme == "immich") { - final proposedRoute = await deepLinkHandler.handleScheme(deepLink, isColdStart); + final proposedRoute = await deepLinkHandler.handleScheme(deepLink, ref, isColdStart); return proposedRoute; } if (deepLink.uri.host == "my.immich.app") { - final proposedRoute = await deepLinkHandler.handleMyImmichApp(deepLink, isColdStart); + final proposedRoute = await deepLinkHandler.handleMyImmichApp(deepLink, ref, isColdStart); return proposedRoute; } 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 5e906b820f..6d2f18f8f5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -59,6 +59,18 @@ class AssetViewer extends ConsumerStatefulWidget { @override ConsumerState createState() => _AssetViewerState(); + + 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(); + } + } } const double _kBottomSheetMinimumExtent = 0.4; @@ -99,13 +111,12 @@ class _AssetViewerState extends ConsumerState { @override void initState() { super.initState(); + assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); pageController = PageController(initialPage: widget.initialIndex); platform = widget.platform ?? const LocalPlatform(); totalAssets = ref.read(timelineServiceProvider).totalAssets; bottomSheetController = DraggableScrollableController(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _onAssetChanged(widget.initialIndex); - }); + WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); reloadSubscription = EventStream.shared.listen(_onEvent); heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; } @@ -143,26 +154,9 @@ class _AssetViewerState extends ConsumerState { return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener); } - void _onAssetChanged(int index) async { - // Validate index bounds and try to get asset, loading buffer if needed + void _precacheAssets(int index) { final timelineService = ref.read(timelineServiceProvider); - final asset = await timelineService.getAssetAsync(index); - - if (asset == null) { - return; - } - - // 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(); - } - - unawaited(ref.read(timelineServiceProvider).preCacheAssets(index)); + unawaited(timelineService.preCacheAssets(index)); _cancelTimers(); // This will trigger the pre-caching of adjacent assets ensuring // that they are ready when the user navigates to them. @@ -181,12 +175,29 @@ class _AssetViewerState extends ConsumerState { _nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null; }); _delayedOperations.add(timer); - - _handleCasting(asset); } - void _handleCasting(BaseAsset asset) { + void _onAssetInit(Duration _) { + _precacheAssets(widget.initialIndex); + _handleCasting(); + } + + void _onAssetChanged(int index) async { + final timelineService = ref.read(timelineServiceProvider); + final asset = await timelineService.getAssetAsync(index); + if (asset == null) { + return; + } + + AssetViewer.setAsset(ref, asset); + _precacheAssets(index); + _handleCasting(); + } + + void _handleCasting() { if (!ref.read(castProvider).isCasting) return; + final asset = ref.read(currentAssetNotifier); + if (asset == null) return; // hide any casting snackbars if they exist context.scaffoldMessenger.hideCurrentSnackBar(); @@ -597,7 +608,7 @@ class _AssetViewerState extends ConsumerState { if (asset == null) return; WidgetsBinding.instance.addPostFrameCallback((_) { - _handleCasting(asset); + _handleCasting(); }); }); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index 88513516eb..94e0a70538 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -75,14 +75,23 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier { } void setAsset(BaseAsset? asset) { + if (asset == state.currentAsset) { + return; + } state = state.copyWith(currentAsset: asset, stackIndex: 0); } void setOpacity(int opacity) { + if (opacity == state.backgroundOpacity) { + return; + } state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls); } void setBottomSheet(bool showing) { + if (showing == state.showingBottomSheet) { + return; + } state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls); if (showing) { ref.read(videoPlayerControlsProvider.notifier).pause(); @@ -90,6 +99,9 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier { } void setControls(bool isShowing) { + if (isShowing == state.showingControls) { + return; + } state = state.copyWith(showingControls: isShowing); } @@ -98,6 +110,9 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier { } void setStackIndex(int index) { + if (index == state.stackIndex) { + return; + } state = state.copyWith(stackIndex: index); } } diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 5eda738e76..50d04f646d 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -5,6 +5,7 @@ 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/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart'; @@ -155,6 +156,7 @@ class _AssetTileWidget extends ConsumerWidget { } else { await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1); ref.read(isPlayingMotionVideoProvider.notifier).playing = false; + AssetViewer.setAsset(ref, asset); ctx.pushRoute( AssetViewerRoute( initialIndex: assetIndex, diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index b675b24418..6226781919 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -1,9 +1,11 @@ import 'package:auto_route/auto_route.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/asset.service.dart' as beta_asset_service; import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider; @@ -16,7 +18,6 @@ import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/memory.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; final deepLinkServiceProvider = Provider( (ref) => DeepLinkService( @@ -71,14 +72,14 @@ class DeepLinkService { ]); } - Future handleScheme(PlatformDeepLink link, bool isColdStart) async { + Future handleScheme(PlatformDeepLink link, WidgetRef ref, bool isColdStart) async { // get everything after the scheme, since Uri cannot parse path final intent = link.uri.host; final queryParams = link.uri.queryParameters; PageRouteInfo? deepLinkRoute = switch (intent) { "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), - "asset" => await _buildAssetDeepLink(queryParams['id'] ?? ''), + "asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), _ => null, }; @@ -95,7 +96,7 @@ class DeepLinkService { return _handleColdStart(deepLinkRoute, isColdStart); } - Future handleMyImmichApp(PlatformDeepLink link, bool isColdStart) async { + Future handleMyImmichApp(PlatformDeepLink link, WidgetRef ref, bool isColdStart) async { final path = link.uri.path; const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; @@ -105,7 +106,7 @@ class DeepLinkService { PageRouteInfo? deepLinkRoute; if (assetRegex.hasMatch(path)) { final assetId = assetRegex.firstMatch(path)?.group(1) ?? ''; - deepLinkRoute = await _buildAssetDeepLink(assetId); + deepLinkRoute = await _buildAssetDeepLink(assetId, ref); } else if (albumRegex.hasMatch(path)) { final albumId = albumRegex.firstMatch(path)?.group(1) ?? ''; deepLinkRoute = await _buildAlbumDeepLink(albumId); @@ -141,13 +142,14 @@ class DeepLinkService { } } - Future _buildAssetDeepLink(String assetId) async { + Future _buildAssetDeepLink(String assetId, WidgetRef ref) async { if (Store.isBetaTimelineEnabled) { final asset = await _betaAssetService.getRemoteAsset(assetId); if (asset == null) { return null; } + AssetViewer.setAsset(ref, asset); return AssetViewerRoute(initialIndex: 0, timelineService: _betaTimelineFactory.fromAssets([asset])); } else { // TODO: Remove this when beta is default