From 82db581cc57bad139311e7f3cc86d7c9418eaf3b Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 16 Mar 2026 19:06:51 +0100 Subject: [PATCH 1/8] feat(mobile): open in browser (#26369) * feat(mobile): open in browser * chore: open in browser instead of webview * chore: allow archived asset * fix: moved openinbrowser above unstack * feat: deeplink into favorites, trash & archived * fix: use remoteId (for tests to succeed) --------- Co-authored-by: Alex --- i18n/en.json | 1 + .../open_in_browser_action_button.widget.dart | 61 +++++++++++++++++++ mobile/lib/utils/action_button.utils.dart | 10 +++ 3 files changed, 72 insertions(+) create mode 100644 mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart diff --git a/i18n/en.json b/i18n/en.json index c3b998ec13..956ed03989 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1651,6 +1651,7 @@ "only_favorites": "Only favorites", "open": "Open", "open_calendar": "Open calendar", + "open_in_browser": "Open in browser", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", diff --git a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart new file mode 100644 index 0000000000..17703d0beb --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class OpenInBrowserActionButton extends ConsumerWidget { + final String remoteId; + final TimelineOrigin origin; + final bool iconOnly; + final bool menuItem; + final Color? iconColor; + + const OpenInBrowserActionButton({ + super.key, + required this.remoteId, + required this.origin, + this.iconOnly = false, + this.menuItem = false, + this.iconColor, + }); + + void _onTap() async { + final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', ''); + + String originPath = ''; + switch (origin) { + case TimelineOrigin.favorite: + originPath = '/favorites'; + break; + case TimelineOrigin.trash: + originPath = '/trash'; + break; + case TimelineOrigin.archive: + originPath = '/archive'; + break; + default: + break; + } + + final url = '$serverEndpoint$originPath/photos/$remoteId'; + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + label: 'open_in_browser'.t(context: context), + iconData: Icons.open_in_browser, + iconColor: iconColor, + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: _onTap, + ); + } +} diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 2e26d8e80d..071956392c 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; @@ -75,6 +76,7 @@ enum ActionButtonType { viewInTimeline, download, upload, + openInBrowser, unstack, archive, unarchive, @@ -149,6 +151,7 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.isStacked, + ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.likeActivity => !context.isInLockedView && context.currentAlbum != null && @@ -236,6 +239,13 @@ enum ActionButtonType { ), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.openInBrowser => OpenInBrowserActionButton( + remoteId: context.asset.remoteId!, + origin: context.timelineOrigin, + iconOnly: iconOnly, + menuItem: menuItem, + iconColor: context.originalTheme?.iconTheme.color, + ), ActionButtonType.similarPhotos => SimilarPhotosActionButton( assetId: (context.asset as RemoteAsset).id, iconOnly: iconOnly, From 4e44fb9cf7ea8de8bb1c61f755c40772a674eacf Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:15:20 +0100 Subject: [PATCH 2/8] fix(web): prevent search page error on missing album filter (#26948) --- .../search/[[photos=photos]]/[[assetId=id]]/+page.svelte | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 701bc0ff59..4f050b8629 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -67,7 +67,7 @@ type SearchTerms = MetadataSearchDto & Pick; let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY)); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); - let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); + let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); $effect(() => { // we want this to *only* be reactive on `terms` @@ -137,15 +137,13 @@ const searchDto: SearchTerms = { page: nextPage, withExif: true, - isVisible: true, - language: $lang, ...terms, }; try { const { albums, assets } = ('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled - ? await searchSmart({ smartSearchDto: searchDto }) + ? await searchSmart({ smartSearchDto: { ...searchDto, language: $lang } }) : await searchAssets({ metadataSearchDto: searchDto }); searchResultAlbums.push(...albums.items); @@ -230,7 +228,7 @@ const onAlbumAddAssets = ({ assetIds }: { assetIds: string[] }) => { cancelMultiselect(assetInteraction); - if (terms.isNotInAlbum.toString() == 'true') { + if (terms.isNotInAlbum) { const assetIdSet = new Set(assetIds); searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id)); } From 9dafc8e8e90cfe3b960a1ba056dd5bd064c1cf37 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:17:55 +0100 Subject: [PATCH 3/8] fix(web): make link fit album card (#26958) --- web/src/lib/components/album-page/album-card-group.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte index 99aa8f2b71..eb931d834c 100644 --- a/web/src/lib/components/album-page/album-card-group.svelte +++ b/web/src/lib/components/album-page/album-card-group.svelte @@ -65,6 +65,7 @@ {#each albums as album, index (album.id)} oncontextmenu(event, album)} > From bba4a00eb11eff7d44d7573d60b608783fdbaf0e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:40:22 +0100 Subject: [PATCH 4/8] chore(deps): update github-actions (#26967) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/check-openapi.yml | 2 +- .github/workflows/cli.yml | 4 +- .github/workflows/codeql-analysis.yml | 6 +-- .github/workflows/docs-build.yml | 4 +- .github/workflows/fix-format.yml | 4 +- .github/workflows/prepare-release.yml | 6 +-- .github/workflows/sdk.yml | 4 +- .github/workflows/test.yml | 54 +++++++++++++-------------- 8 files changed, 42 insertions(+), 42 deletions(-) diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index ca9f91bbe8..b725b45f6c 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30 + uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index a2c763a0f6..d3eb66810e 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -42,10 +42,10 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './cli/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4f093a170e..3450fe96bb 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 28828f22c6..02d7b3456a 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -67,10 +67,10 @@ jobs: fetch-depth: 0 - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './docs/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 1daa279cd2..0091bcef89 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -29,10 +29,10 @@ jobs: persist-credentials: true - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index a1d31a61ea..6030dc8752 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -63,13 +63,13 @@ jobs: ref: main - name: Install uv - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 1bcdec4747..2da7d79b26 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -30,10 +30,10 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './open-api/typescript-sdk/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1cad2b0023..2a2ebe2389 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,9 +75,9 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -119,9 +119,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -166,9 +166,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -208,9 +208,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -252,9 +252,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -290,9 +290,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -338,9 +338,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -385,9 +385,9 @@ jobs: submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -424,9 +424,9 @@ jobs: submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -496,9 +496,9 @@ jobs: submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -620,7 +620,7 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 with: python-version: 3.11 - name: Install dependencies @@ -661,9 +661,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './.github/.nvmrc' cache: 'pnpm' @@ -712,9 +712,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -774,9 +774,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' From 16749ff8ba6f69bec2dadc50b9aa083fdcf15b02 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:33:43 +0000 Subject: [PATCH 5/8] fix(server): sync files to disk (#26881) Ensure that all files are flushed after they've been written. At current, files are not explicitly flushed to disk, which can cause data corruption. In extreme circumstances, it's possible that uploaded files may not ever be persisted at all. --- .../src/middleware/file-upload.interceptor.ts | 108 ++++++++---------- server/src/repositories/storage.repository.ts | 2 +- 2 files changed, 49 insertions(+), 61 deletions(-) diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 6dfd11ee4b..63acb13789 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -3,13 +3,16 @@ import { PATH_METADATA } from '@nestjs/common/constants'; import { Reflector } from '@nestjs/core'; import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils'; import { NextFunction, RequestHandler } from 'express'; -import multer, { StorageEngine, diskStorage } from 'multer'; +import multer from 'multer'; import { createHash, randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { pipeline } from 'node:stream'; import { Observable } from 'rxjs'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { RouteKey } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; import { AssetMediaService } from 'src/services/asset-media.service'; import { ImmichFile, UploadFile, UploadFiles } from 'src/types'; import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util'; @@ -26,8 +29,6 @@ export function getFiles(files: UploadFiles) { }; } -type DiskStorageCallback = (error: Error | null, result: string) => void; - type ImmichMulterFile = Express.Multer.File & { uuid: string }; interface Callback { @@ -35,34 +36,21 @@ interface Callback { (error: null, result: T): void; } -const callbackify = (target: (...arguments_: any[]) => T, callback: Callback) => { - try { - return callback(null, target()); - } catch (error: Error | any) { - return callback(error); - } -}; - @Injectable() export class FileUploadInterceptor implements NestInterceptor { private handlers: { userProfile: RequestHandler; assetUpload: RequestHandler; }; - private defaultStorage: StorageEngine; constructor( private reflect: Reflector, private assetService: AssetMediaService, + private storageRepository: StorageRepository, private logger: LoggingRepository, ) { this.logger.setContext(FileUploadInterceptor.name); - this.defaultStorage = diskStorage({ - filename: this.filename.bind(this), - destination: this.destination.bind(this), - }); - const instance = multer({ fileFilter: this.fileFilter.bind(this), storage: { @@ -99,60 +87,60 @@ export class FileUploadInterceptor implements NestInterceptor { } private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) { - return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback); - } - - private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { - return callbackify( - () => this.assetService.getUploadFilename(asUploadRequest(request, file)), - callback as Callback, - ); - } - - private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { - return callbackify( - () => this.assetService.getUploadFolder(asUploadRequest(request, file)), - callback as Callback, - ); + try { + callback(null, this.assetService.canUploadFile(asUploadRequest(request, file))); + } catch (error: Error | any) { + callback(error); + } } private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback>) { - (file as ImmichMulterFile).uuid = randomUUID(); - request.on('error', (error) => { this.logger.warn('Request error while uploading file, cleaning up', error); this.assetService.onUploadError(request, file).catch(this.logger.error); }); - if (!this.isAssetUploadFile(file)) { - this.defaultStorage._handleFile(request, file, callback); - return; - } + try { + (file as ImmichMulterFile).uuid = randomUUID(); - const hash = createHash('sha1'); - file.stream.on('data', (chunk) => hash.update(chunk)); - this.defaultStorage._handleFile(request, file, (error, info) => { - if (error) { - hash.destroy(); - callback(error); - } else { - callback(null, { ...info, checksum: hash.digest() }); - } - }); + const uploadRequest = asUploadRequest(request, file); + + const path = join( + this.assetService.getUploadFolder(uploadRequest), + this.assetService.getUploadFilename(uploadRequest), + ); + + const writeStream = this.storageRepository.createWriteStream(path); + const hash = file.fieldname === UploadFieldName.ASSET_DATA ? createHash('sha1') : null; + + let size = 0; + + file.stream.on('data', (chunk) => { + hash?.update(chunk); + size += chunk.length; + }); + + pipeline(file.stream, writeStream, (error) => { + if (error) { + hash?.destroy(); + return callback(error); + } + callback(null, { + path, + size, + checksum: hash?.digest(), + }); + }); + } catch (error: Error | any) { + callback(error); + } } - private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) { - this.defaultStorage._removeFile(request, file, callback); - } - - private isAssetUploadFile(file: Express.Multer.File) { - switch (file.fieldname as UploadFieldName) { - case UploadFieldName.ASSET_DATA: { - return true; - } - } - - return false; + private removeFile(_request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) { + this.storageRepository + .unlink(file.path) + .then(() => callback(null)) + .catch(callback); } private getHandler(route: RouteKey) { diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 5a1a936e77..c7ba4ab6cc 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -63,7 +63,7 @@ export class StorageRepository { } createWriteStream(filepath: string): Writable { - return createWriteStream(filepath, { flags: 'w' }); + return createWriteStream(filepath, { flags: 'w', flush: true }); } createOrOverwriteFile(filepath: string, buffer: Buffer) { From ac6938a629206a6b8e7cb949d0c13e37579276c0 Mon Sep 17 00:00:00 2001 From: Preslav Penchev <55917783+pressslav@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:38:06 +0000 Subject: [PATCH 6/8] fix(web): allow pasting PIN code from clipboard or password manager (#26944) * fix(web): allow pasting PIN code from clipboard or password manager The keydown handler was blocking Ctrl+V/Cmd+V since it called preventDefault() on all non-numeric keys. Also adds an onpaste handler to distribute pasted digits across the individual inputs. * refactor: handle paste in handleInput, remove maxlength * cleanup + fix digit focus --------- Co-authored-by: Preslav Penchev Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> --- .../user-settings-page/PinCodeInput.svelte | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/web/src/lib/components/user-settings-page/PinCodeInput.svelte b/web/src/lib/components/user-settings-page/PinCodeInput.svelte index c438d8f0b1..2ab48c0afe 100644 --- a/web/src/lib/components/user-settings-page/PinCodeInput.svelte +++ b/web/src/lib/components/user-settings-page/PinCodeInput.svelte @@ -49,25 +49,22 @@ const handleInput = (event: Event, index: number) => { const target = event.target as HTMLInputElement; - let currentPinValue = target.value; + const digits = target.value.replaceAll(/\D/g, '').slice(0, pinLength - index); - if (target.value.length > 1) { - currentPinValue = value.slice(0, 1); - } - - if (Number.isNaN(Number(value))) { + if (digits.length === 0) { pinValues[index] = ''; - target.value = ''; + value = pinValues.join('').trim(); return; } - pinValues[index] = currentPinValue; + for (let i = 0; i < digits.length; i++) { + pinValues[index + i] = digits[i]; + } value = pinValues.join('').trim(); - if (value && index < pinLength - 1) { - focusNext(index); - } + const lastFilledIndex = Math.min(index + digits.length, pinLength - 1); + pinCodeInputElements[lastFilledIndex]?.focus(); if (value.length === pinLength) { onFilled?.(value); @@ -104,12 +101,6 @@ } return; } - default: { - if (Number.isNaN(Number(event.key))) { - event.preventDefault(); - } - break; - } } } @@ -125,7 +116,6 @@ {type} inputmode="numeric" pattern="[0-9]*" - maxlength="1" bind:this={pinCodeInputElements[index]} id="pin-code-{index}" class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light" From 9b0b2bfcf2ffd6d49b355e39ccf9ffb73cc38d20 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:39:39 +0100 Subject: [PATCH 7/8] fix(web): jump to primary stacked asset from memory (#26978) --- .../memory-page/memory-viewer.svelte | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 2098f48049..d20eb29227 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -63,8 +63,9 @@ let playerInitialized = $state(false); let paused = $state(false); let current = $state(undefined); - let currentMemoryAssetFull = $derived.by(async () => - current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, + const currentAssetId = $derived(current?.asset.id); + const currentMemoryAssetFull = $derived.by(async () => + currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined, ); let currentTimelineAssets = $derived(current?.memory.assets ?? []); let viewerAssets = $derived([ @@ -550,14 +551,18 @@
- + {#await currentMemoryAssetFull then asset} + {#if asset} + + {/if} + {/await}
From 677cb660f55fe7d81888c1a794993f8c6c21e402 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:43:14 +0000 Subject: [PATCH 8/8] fix(mobile): reflect asset deletions instantly (#26835) Sometimes the current asset won't update when deleted, or it won't refresh until an event (like showing details) happens. --- .../archive_action_button.widget.dart | 6 +- .../delete_action_button.widget.dart | 6 +- ...delete_permanent_action_button.widget.dart | 6 +- ...e_to_lock_folder_action_button.widget.dart | 6 +- ...emove_from_album_action_button.widget.dart | 6 +- .../trash_action_button.widget.dart | 6 +- .../unarchive_action_button.widget.dart | 6 +- .../asset_viewer/asset_viewer.page.dart | 64 ++++++++----------- 8 files changed, 47 insertions(+), 59 deletions(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart index 7c7e96c1c5..a673dff1d7 100644 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart @@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; Future performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { if (!context.mounted) return; - final result = await ref.read(actionProvider.notifier).archive(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).archive(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart index 94ee1b2343..2121ef3159 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget { if (confirm != true) return; } - final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index 710ec506c2..27a1a4d8af 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget { false; if (!confirm) return; - final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'delete_permanently_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index 341791c1af..2f7c3899eb 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; Future performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { if (!context.mounted) return; - final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'move_to_lock_folder_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart index fd88e94cf7..97a36a56dc 100644 --- a/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart @@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget { return; } - final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'remove_from_album_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart index 5f1e385769..e95569af45 100644 --- a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart @@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget { return; } - final result = await ref.read(actionProvider.notifier).trash(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).trash(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart index 8cf0bcba92..98e868d953 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart @@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; Future performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { if (!context.mounted) return; - final result = await ref.read(actionProvider.notifier).unArchive(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).unArchive(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { 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 903105406c..4d8954d4ef 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -81,19 +81,17 @@ class _AssetViewerState extends ConsumerState { late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); late int _currentPage = widget.initialIndex; + late int _totalAssets = ref.read(timelineServiceProvider).totalAssets; StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; - bool _assetReloadRequested = false; - void _onTapNavigate(int direction) { final page = _pageController.page?.toInt(); if (page == null) return; final target = page + direction; - final maxPage = ref.read(timelineServiceProvider).totalAssets - 1; + final maxPage = _totalAssets - 1; if (target >= 0 && target <= maxPage) { - _currentPage = target; _pageController.jumpToPage(target); _onAssetChanged(target); } @@ -141,7 +139,6 @@ class _AssetViewerState extends ConsumerState { final page = _pageController.page?.round(); if (page != null && page != _currentPage) { - _currentPage = page; _onAssetChanged(page); } return false; @@ -153,8 +150,9 @@ class _AssetViewerState extends ConsumerState { } void _onAssetChanged(int index) async { - final timelineService = ref.read(timelineServiceProvider); - final asset = await timelineService.getAssetAsync(index); + _currentPage = index; + + final asset = await ref.read(timelineServiceProvider).getAssetAsync(index); if (asset == null) return; AssetViewer._setAsset(ref, asset); @@ -193,11 +191,20 @@ class _AssetViewerState extends ConsumerState { case TimelineReloadEvent(): _onTimelineReloadEvent(); case ViewerReloadAssetEvent(): - _assetReloadRequested = true; + _onViewerReloadEvent(); default: } } + void _onViewerReloadEvent() { + if (_totalAssets <= 1) return; + + final index = _pageController.page?.round() ?? 0; + final target = index >= _totalAssets - 1 ? index - 1 : index + 1; + _pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut); + _onAssetChanged(target); + } + void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); final totalAssets = timelineService.totalAssets; @@ -207,43 +214,24 @@ class _AssetViewerState extends ConsumerState { return; } - var index = _pageController.page?.round() ?? 0; 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); - } - } + final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null; + final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1); - if (index >= totalAssets) { - index = totalAssets - 1; - _currentPage = index; + if (index != _currentPage) { _pageController.jumpToPage(index); + _onAssetChanged(index); + } else if (currentAsset != null && assetIndex == null) { + _onAssetChanged(index); } - if (_assetReloadRequested) { - _assetReloadRequested = false; - _onAssetReloadEvent(index); + if (_totalAssets != totalAssets) { + setState(() { + _totalAssets = totalAssets; + }); } } - void _onAssetReloadEvent(int index) async { - final timelineService = ref.read(timelineServiceProvider); - - final newAsset = await timelineService.getAssetAsync(index); - if (newAsset == null) return; - - final currentAsset = ref.read(assetViewerProvider).currentAsset; - - // Do not reload if the asset has not changed - if (newAsset.heroTag == currentAsset?.heroTag) return; - - _onAssetChanged(index); - } - void _setSystemUIMode(bool controls, bool details) { final mode = !controls || (CurrentPlatform.isIOS && details) ? SystemUiMode.immersiveSticky @@ -301,7 +289,7 @@ class _AssetViewerState extends ConsumerState { : CurrentPlatform.isIOS ? const FastScrollPhysics() : const FastClampingScrollPhysics(), - itemCount: ref.read(timelineServiceProvider).totalAssets, + itemCount: _totalAssets, itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), ),