From f52bd9f38a1e75bf86100c03fa22387a7febf57d Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 6 Jan 2026 10:02:10 -0500 Subject: [PATCH 01/17] feat: use prettier for i18n translations (#24623) --- .github/workflows/test.yml | 4 ++-- i18n/.prettierrc | 5 +++++ i18n/package.json | 13 +++++++++++++ mise.toml | 2 +- mobile/makefile | 2 +- pnpm-lock.yaml | 9 +++++++++ pnpm-workspace.yaml | 1 + web/package.json | 3 +-- web/src/lib/i18n.spec.ts | 4 ++++ 9 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 i18n/.prettierrc create mode 100644 i18n/package.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f333fcebf4..b00612b41b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -298,9 +298,9 @@ jobs: cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' - name: Install dependencies - run: pnpm --filter=immich-web install --frozen-lockfile + run: pnpm --filter=immich-i18n install --frozen-lockfile - name: Format - run: pnpm --filter=immich-web format:i18n + run: pnpm --filter=immich-i18n format:fix - name: Find file changes uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 id: verify-changed-files diff --git a/i18n/.prettierrc b/i18n/.prettierrc new file mode 100644 index 0000000000..30581eb7d1 --- /dev/null +++ b/i18n/.prettierrc @@ -0,0 +1,5 @@ +{ + "jsonRecursiveSort": true, + "jsonSortOrder": "{\"/.*/\": \"lexical\"}", + "plugins": ["prettier-plugin-sort-json"] +} diff --git a/i18n/package.json b/i18n/package.json new file mode 100644 index 0000000000..19d78c49b7 --- /dev/null +++ b/i18n/package.json @@ -0,0 +1,13 @@ +{ + "name": "immich-i18n", + "version": "1.0.0", + "private": true, + "scripts": { + "format": "prettier --check .", + "format:fix": "prettier --write ." + }, + "devDependencies": { + "prettier": "^3.7.4", + "prettier-plugin-sort-json": "^4.1.1" + } +} diff --git a/mise.toml b/mise.toml index 276ce87d51..a4f597662a 100644 --- a/mise.toml +++ b/mise.toml @@ -34,4 +34,4 @@ run = { task = ":i18n:format-fix" } [tasks."i18n:format-fix"] dir = "i18n" -run = "pnpm dlx sort-json *.json" +run = "pnpm run format:fix" diff --git a/mobile/makefile b/mobile/makefile index b90e95c902..3b211bcd09 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -33,7 +33,7 @@ migration: dart run drift_dev make-migrations translation: - npm --prefix ../web run format:i18n + npm --prefix ../i18n run format:fix dart run easy_localization:generate -S ../i18n dart run bin/generate_keys.dart dart format lib/generated/codegen_loader.g.dart diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce68fa76c3..91285d6784 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,6 +292,15 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + i18n: + devDependencies: + prettier: + specifier: ^3.7.4 + version: 3.7.4 + prettier-plugin-sort-json: + specifier: ^4.1.1 + version: 4.1.1(prettier@3.7.4) + open-api/typescript-sdk: dependencies: '@oazapfts/runtime': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 33aaa744b0..c7ec4739ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - cli - docs - e2e + - i18n - open-api/typescript-sdk - server - plugins diff --git a/web/package.json b/web/package.json index 39d9921080..52d06bb519 100644 --- a/web/package.json +++ b/web/package.json @@ -17,8 +17,7 @@ "lint": "eslint . --max-warnings 0 --concurrency 4", "lint:fix": "pnpm run lint --fix", "format": "prettier --check .", - "format:fix": "prettier --write . && pnpm run format:i18n", - "format:i18n": "pnpm dlx sort-json ../i18n/*.json", + "format:fix": "prettier --write .", "test": "vitest", "test:cov": "vitest --coverage", "test:watch": "vitest dev", diff --git a/web/src/lib/i18n.spec.ts b/web/src/lib/i18n.spec.ts index 63aae0419c..325e7bad5e 100644 --- a/web/src/lib/i18n.spec.ts +++ b/web/src/lib/i18n.spec.ts @@ -7,6 +7,10 @@ describe('i18n', () => { const languageFiles = readdirSync('../i18n').sort(); for (const filename of languageFiles) { test(`${filename} should have a loader`, async () => { + if (!filename.endsWith('.json') || filename == 'package.json') { + return; + } + const code = filename.replaceAll('.json', ''); const item = langs.find((lang) => lang.weblateCode === code || lang.code === code); expect(item, `${filename} has no loader`).toBeDefined(); From c411151560cb1cea04f2891a4bae7327d095fcee Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 6 Jan 2026 09:07:44 -0600 Subject: [PATCH 02/17] chore: docs for contributing (#25082) --- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ docs/docs/developer/setup.md | 4 ++++ 2 files changed, 35 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..7199043658 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to Immich + +We appreciate every contribution, and we're happy about every new contributor. So please feel invited to help make Immich a better product! + +## Getting started + +To get you started quickly we have detailed guides for the dev setup on our [website](https://docs.immich.app/developer/setup). If you prefer, you can also use [Devcontainers](https://docs.immich.app/developer/devcontainers). +There are also additional resources about Immich's architecture, database migrations, the use of OpenAPI, and more in our [developer documentation](https://docs.immich.app/developer/architecture). + +## General + +Please try to keep pull requests as focused as possible. A PR should do exactly one thing and not bleed into other, unrelated areas. The smaller a PR, the fewer changes are likely needed, and the quicker it will likely be merged. For larger/more impactful PRs, please reach out to us first to discuss your plans. The best way to do this is through our [Discord](https://discord.immich.app). We have a dedicated `#contributing` channel there. Additionally, please fill out the entire template when opening a PR. + +## Finding work + +If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on! + +## Use of generative AI + +We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template. + +## Feature freezes + +From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on: + +* Sharing/Asset ownership +* (External) libraries + +## Non-code contributions + +If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated. diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index 23c1862c19..fbda3c2983 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -4,6 +4,10 @@ sidebar_position: 2 # Setup +:::warning +Make sure to read the [`CONTRIBUTING.md`](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before you dive into the code. +::: + :::note If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can: From 4cb56edebfdf3e96f782b5ddd528a83fc8b8787c Mon Sep 17 00:00:00 2001 From: fabb <153960+fabb@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:08:54 +0100 Subject: [PATCH 03/17] fix: enter now submits the date modals (#25053) * fix: enter now submits the date modals * use FormModal * apply prettier * fix unit test --- .../lib/modals/AssetChangeDateModal.svelte | 48 +++---- .../AssetSelectionChangeDateModal.spec.ts | 4 +- .../AssetSelectionChangeDateModal.svelte | 126 +++++++++--------- web/src/lib/modals/NavigateToDateModal.svelte | 47 +++---- 4 files changed, 111 insertions(+), 114 deletions(-) diff --git a/web/src/lib/modals/AssetChangeDateModal.svelte b/web/src/lib/modals/AssetChangeDateModal.svelte index 7034493924..e94f1f7afc 100644 --- a/web/src/lib/modals/AssetChangeDateModal.svelte +++ b/web/src/lib/modals/AssetChangeDateModal.svelte @@ -5,7 +5,7 @@ import { getPreferredTimeZone, getTimezones, toIsoDate } from '$lib/modals/timezone-utils'; import { handleError } from '$lib/utils/handle-error'; import { updateAsset } from '@immich/sdk'; - import { Button, HStack, Label, Modal, ModalBody, ModalFooter } from '@immich/ui'; + import { FormModal, Label } from '@immich/ui'; import { mdiCalendarEdit } from '@mdi/js'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; @@ -28,7 +28,7 @@ // the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone)); - const handleClose = async () => { + const onSubmit = async () => { if (!date.isValid || !selectedOption) { onClose(false); return; @@ -49,25 +49,25 @@ const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true })); - onClose(false)} size="small"> - - - - {#if timezoneInput} -
- -
- {/if} -
- - - - - - -
+ onClose(false)} + {onSubmit} + submitText={$t('confirm')} + disabled={!date.isValid || !selectedOption} + size="small" +> + + + {#if timezoneInput} +
+ +
+ {/if} +
diff --git a/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts b/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts index 65b77ce5cf..dfb86b6744 100644 --- a/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts +++ b/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts @@ -17,8 +17,8 @@ describe('DateSelectionModal component', () => { const getRelativeInputToggle = () => screen.getByTestId('edit-by-offset-switch'); const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement; const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement; - const getCancelButton = () => screen.getByText('cancel'); - const getConfirmButton = () => screen.getByText('confirm'); + const getCancelButton = () => screen.getByRole('button', { name: /cancel/i }); + const getConfirmButton = () => screen.getByRole('button', { name: /confirm/i }); beforeEach(() => { vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); diff --git a/web/src/lib/modals/AssetSelectionChangeDateModal.svelte b/web/src/lib/modals/AssetSelectionChangeDateModal.svelte index 8eb1a481cc..e60e4cb8a5 100644 --- a/web/src/lib/modals/AssetSelectionChangeDateModal.svelte +++ b/web/src/lib/modals/AssetSelectionChangeDateModal.svelte @@ -8,7 +8,7 @@ import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { updateAssets } from '@immich/sdk'; - import { Button, Field, HStack, Label, Modal, ModalBody, ModalFooter, Switch } from '@immich/ui'; + import { Field, FormModal, Label, Switch } from '@immich/ui'; import { mdiCalendarEdit } from '@mdi/js'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; @@ -30,7 +30,7 @@ // the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone)); - const handleConfirm = async () => { + const onSubmit = async () => { const ids = getOwnedAssetsWithWarning(assets, $user); try { if (showRelative && (selectedDuration || selectedOption)) { @@ -63,66 +63,62 @@ const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true })); - onClose(false)} size="small"> - - - - - {#if showRelative} - - - {:else} - - - {/if} -
- (lastSelectedTimezone = option as ZoneOption)} - > -
- -
- - - - - - -
+ onClose(false)} + {onSubmit} + submitText={$t('confirm')} + disabled={!date.isValid} + size="small" +> + + + + {#if showRelative} + + + {:else} + + + {/if} +
+ (lastSelectedTimezone = option as ZoneOption)} + > +
+ +
diff --git a/web/src/lib/modals/NavigateToDateModal.svelte b/web/src/lib/modals/NavigateToDateModal.svelte index 365cbdb21c..51f968c6df 100644 --- a/web/src/lib/modals/NavigateToDateModal.svelte +++ b/web/src/lib/modals/NavigateToDateModal.svelte @@ -3,10 +3,11 @@ import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils'; - import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui'; + import { FormModal, HStack, VStack } from '@immich/ui'; import { mdiNavigationVariantOutline } from '@mdi/js'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; + interface Props { timelineManager: TimelineManager; onClose: (asset?: TimelineAsset) => void; @@ -20,7 +21,7 @@ // the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list let selectedOption: ZoneOption | undefined = $derived(getPreferredTimeZone(initialDate, undefined, timezones)); - const handleClose = async () => { + const onSubmit = async () => { if (!date.isValid || !selectedOption) { onClose(); return; @@ -36,26 +37,26 @@ const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true })); - onClose()}> - - - - - - - - - - - + onClose()} + {onSubmit} + submitText={$t('confirm')} + disabled={!date.isValid || !selectedOption} + size="medium" +> + - - + - - + + + + + From ded980bfc385d0d6eed4d7020dbf2e48f5fae43c Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Tue, 6 Jan 2026 23:23:28 +0800 Subject: [PATCH 04/17] fix(web): improve text contrast in minimized upload panel (#25075) The minimized upload status buttons in dark mode had poor text contrast because they used `text-gray-200` on colored backgrounds. Changed to `text-light` which provides better contrast for both light and dark modes on `bg-primary` and `bg-danger` backgrounds. Fixes #24683 Signed-off-by: majiayu000 <1835304752@qq.com> --- web/src/lib/components/shared-components/upload-panel.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index 021940d506..13b980f085 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -131,7 +131,7 @@ type="button" in:scale={{ duration: 250, easing: quartInOut }} onclick={() => (showDetail = true)} - class="absolute -start-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-primary p-5 text-xs text-gray-200" + class="absolute -start-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-primary p-5 text-xs text-light" > {$remainingUploads.toLocaleString($locale)} @@ -140,7 +140,7 @@ type="button" in:scale={{ duration: 250, easing: quartInOut }} onclick={() => (showDetail = true)} - class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-danger p-5 text-xs text-gray-200" + class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-danger p-5 text-xs text-light" > {$stats.errors.toLocaleString($locale)} From f0f1687c79e873ccdfd5be18b0b1a3ab75bac25e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 6 Jan 2026 10:41:53 -0500 Subject: [PATCH 05/17] refactor: asset view navbar onclose (#25087) --- .../components/asset-viewer/asset-viewer-nav-bar.svelte | 6 ++---- web/src/lib/components/asset-viewer/asset-viewer.svelte | 9 +++------ .../share-page/individual-shared-viewer.svelte | 2 -- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 08957a5340..43fe6978e5 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -69,7 +69,6 @@ album?: AlbumResponseDto | null; person?: PersonResponseDto | null; stack?: StackResponseDto | null; - showCloseButton?: boolean; showSlideshow?: boolean; onZoomImage: () => void; onCopyImage?: () => Promise; @@ -79,7 +78,7 @@ onRunJob: (name: AssetJobName) => void; onPlaySlideshow: () => void; // export let showEditorHandler: () => void; - onClose: () => void; + onClose?: () => void; motionPhoto?: Snippet; playOriginalVideo: boolean; setPlayOriginalVideo: (value: boolean) => void; @@ -90,7 +89,6 @@ album = null, person = null, stack = null, - showCloseButton = true, showSlideshow = false, onZoomImage, onCopyImage, @@ -128,7 +126,7 @@ class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200" >
- {#if showCloseButton} + {#if onClose} {/if}
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 7b07d57fd1..f4ad70c85a 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -65,8 +65,7 @@ preAction?: PreAction | undefined; onAction?: OnAction | undefined; onUndoDelete?: OnUndoDelete | undefined; - showCloseButton?: boolean; - onClose: (asset: AssetResponseDto) => void; + onClose?: (asset: AssetResponseDto) => void; onNext: () => Promise; onPrevious: () => Promise; onRandom: () => Promise<{ id: string } | undefined>; @@ -84,7 +83,6 @@ preAction = undefined, onAction = undefined, onUndoDelete = undefined, - showCloseButton, onClose, onNext, onPrevious, @@ -203,7 +201,7 @@ }; const closeViewer = () => { - onClose(asset); + onClose?.(asset); }; const closeEditor = () => { @@ -411,7 +409,6 @@ {album} {person} {stack} - {showCloseButton} showSlideshow={true} onZoomImage={zoomToggle} onCopyImage={copyImage} @@ -420,7 +417,7 @@ {onUndoDelete} onRunJob={handleRunJob} onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} - onClose={closeViewer} + onClose={onClose ? () => onClose(asset) : undefined} {playOriginalVideo} {setPlayOriginalVideo} > diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index eb25d102fb..f548de7017 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -146,12 +146,10 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} Promise.resolve(false)} onNext={() => Promise.resolve(false)} onRandom={() => Promise.resolve(undefined)} - onClose={() => {}} /> {/await} {/await} From 1a24a2d35e471e3ece11411a86d5e81343bfd893 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 6 Jan 2026 17:35:37 -0500 Subject: [PATCH 06/17] refactor: asset viewer navbar actions (#25091) --- open-api/typescript-sdk/src/fetch-client.ts | 2 +- .../asset-viewer/actions/close-action.svelte | 23 --------- .../actions/motion-photo-action.svelte | 21 -------- .../actions/show-detail-action.svelte | 22 -------- .../asset-viewer/asset-viewer-nav-bar.svelte | 50 +++++++++---------- .../asset-viewer/asset-viewer.svelte | 15 ++---- .../managers/asset-viewer-manager.svelte.ts | 11 +--- web/src/lib/services/asset.service.ts | 45 ++++++++++++++++- 8 files changed, 72 insertions(+), 117 deletions(-) delete mode 100644 web/src/lib/components/asset-viewer/actions/close-action.svelte delete mode 100644 web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte delete mode 100644 web/src/lib/components/asset-viewer/actions/show-detail-action.svelte diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b7c980f4f4..5e024b560c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -8,7 +8,7 @@ import * as Oazapfts from "@oazapfts/runtime"; import * as QS from "@oazapfts/runtime/query"; export const defaults: Oazapfts.Defaults = { headers: {}, - baseUrl: "/api", + baseUrl: "/api" }; const oazapfts = Oazapfts.runtime(defaults); export const servers = { diff --git a/web/src/lib/components/asset-viewer/actions/close-action.svelte b/web/src/lib/components/asset-viewer/actions/close-action.svelte deleted file mode 100644 index 7b3f525a3a..0000000000 --- a/web/src/lib/components/asset-viewer/actions/close-action.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte deleted file mode 100644 index ee09c2976b..0000000000 --- a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - onClick(!isPlaying)} -/> diff --git a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte b/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte deleted file mode 100644 index 99b6c1dcde..0000000000 --- a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 43fe6978e5..a5050075dc 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -7,7 +7,6 @@ import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; - import CloseAction from '$lib/components/asset-viewer/actions/close-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; @@ -20,12 +19,10 @@ import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte'; import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte'; - import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AppRoute } from '$lib/constants'; - import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; @@ -44,9 +41,9 @@ type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; - import { IconButton } from '@immich/ui'; + import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui'; import { - mdiAlertOutline, + mdiArrowLeft, mdiCogRefreshOutline, mdiCompare, mdiContentCopy, @@ -61,7 +58,6 @@ mdiUpload, mdiVideoOutline, } from '@mdi/js'; - import type { Snippet } from 'svelte'; import { t } from 'svelte-i18n'; interface Props { @@ -79,7 +75,6 @@ onPlaySlideshow: () => void; // export let showEditorHandler: () => void; onClose?: () => void; - motionPhoto?: Snippet; playOriginalVideo: boolean; setPlayOriginalVideo: (value: boolean) => void; } @@ -98,7 +93,6 @@ onRunJob, onPlaySlideshow, onClose, - motionPhoto, playOriginalVideo = false, setPlayOriginalVideo, }: Props = $props(); @@ -109,7 +103,15 @@ let isLocked = $derived(asset.visibility === AssetVisibility.Locked); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); - const { Share } = $derived(getAssetActions($t, asset)); + const Close: ActionItem = { + title: $t('go_back'), + icon: mdiArrowLeft, + $if: () => !!onClose, + onAction: () => onClose?.(), + shortcuts: [{ key: 'Escape' }], + }; + + const { Share, Offline, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(getAssetActions($t, asset)); // $: showEditorButton = // isOwner && @@ -122,30 +124,26 @@ // !asset.livePhotoVideoId; + +
- {#if onClose} - - {/if} +
+
- {#if asset.isOffline} - assetViewerManager.toggleDetailPanel()} - aria-label={$t('asset_offline')} - /> - {/if} - {#if asset.livePhotoVideoId} - {@render motionPhoto?.()} - {/if} + + + + {#if asset.type === AssetTypeEnum.Image}
{/if} @@ -483,7 +474,7 @@ {:else} {#key asset.id} {#if asset.type === AssetTypeEnum.Image} - {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} + {#if assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId} navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} - onVideoEnded={() => (shouldPlayMotionPhoto = false)} + onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)} {playOriginalVideo} /> {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 56470eac35..7b482faa76 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -3,15 +3,8 @@ import { PersistedLocalStorage } from '$lib/utils/persisted'; const isShowDetailPanel = new PersistedLocalStorage('asset-viewer-state', false); export class AssetViewerManager { - #isShowActivityPanel = $state(false); - - get isShowActivityPanel() { - return this.#isShowActivityPanel; - } - - private set isShowActivityPanel(value: boolean) { - this.#isShowActivityPanel = value; - } + isShowActivityPanel = $state(false); + isPlayingMotionPhoto = $state(false); get isShowDetailPanel() { return isShowDetailPanel.current; diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index a64da2a6d6..de5223db23 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -1,10 +1,17 @@ +import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import { user as authUser } from '$lib/stores/user.store'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk'; import { modalManager, type ActionItem } from '@immich/ui'; -import { mdiShareVariantOutline } from '@mdi/js'; +import { + mdiAlertOutline, + mdiInformationOutline, + mdiMotionPauseOutline, + mdiMotionPlayOutline, + mdiShareVariantOutline, +} from '@mdi/js'; import type { MessageFormatter } from 'svelte-i18n'; import { get } from 'svelte/store'; @@ -16,7 +23,41 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }), }; - return { Share }; + const PlayMotionPhoto: ActionItem = { + title: $t('play_motion_photo'), + icon: mdiMotionPlayOutline, + $if: () => !!asset.livePhotoVideoId && !assetViewerManager.isPlayingMotionPhoto, + onAction: () => { + assetViewerManager.isPlayingMotionPhoto = true; + }, + }; + + const StopMotionPhoto: ActionItem = { + title: $t('stop_motion_photo'), + icon: mdiMotionPauseOutline, + $if: () => !!asset.livePhotoVideoId && assetViewerManager.isPlayingMotionPhoto, + onAction: () => { + assetViewerManager.isPlayingMotionPhoto = false; + }, + }; + + const Offline: ActionItem = { + title: $t('asset_offline'), + icon: mdiAlertOutline, + color: 'danger', + $if: () => !!asset.isOffline, + onAction: () => assetViewerManager.toggleDetailPanel(), + }; + + const Info: ActionItem = { + title: $t('info'), + icon: mdiInformationOutline, + $if: () => asset.hasMetadata, + onAction: () => assetViewerManager.toggleDetailPanel(), + shortcuts: [{ key: 'i' }], + }; + + return { Share, PlayMotionPhoto, StopMotionPhoto, Offline, Info }; }; export const handleReplaceAsset = async (oldAssetId: string) => { From 1293e473cad99672d3a932e12cd8b0e82aa37a91 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 6 Jan 2026 18:51:19 -0500 Subject: [PATCH 07/17] refactor: cast button (#25101) --- web/src/lib/cast/cast-button.svelte | 24 ------------------- .../components/album-page/album-viewer.svelte | 7 ++++-- .../asset-viewer/asset-viewer-nav-bar.svelte | 7 +++--- .../navigation-bar/navigation-bar.svelte | 7 ++++-- web/src/lib/managers/cast-manager.svelte.ts | 5 +++- web/src/lib/services/app.service.ts | 19 +++++++++++++++ .../[[assetId=id]]/+page.svelte | 7 ++++-- 7 files changed, 42 insertions(+), 34 deletions(-) delete mode 100644 web/src/lib/cast/cast-button.svelte create mode 100644 web/src/lib/services/app.service.ts diff --git a/web/src/lib/cast/cast-button.svelte b/web/src/lib/cast/cast-button.svelte deleted file mode 100644 index 392418daa5..0000000000 --- a/web/src/lib/cast/cast-button.svelte +++ /dev/null @@ -1,24 +0,0 @@ - - -{#if castManager.availableDestinations.length > 0 && castManager.availableDestinations[0].type === CastDestinationType.GCAST} - void GCastDestination.showCastDialog()} - aria-label={$t('cast')} - /> -{/if} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index d5fdb36822..b7fcaa88ec 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,6 +1,6 @@ + {#if sharedLink.allowUpload} import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; - import CastButton from '$lib/cast/cast-button.svelte'; import ActionButton from '$lib/components/ActionButton.svelte'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; @@ -24,6 +23,7 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AppRoute } from '$lib/constants'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { getGlobalActions } from '$lib/services/app.service'; import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { user } from '$lib/stores/user.store'; @@ -111,6 +111,8 @@ shortcuts: [{ key: 'Escape' }], }; + const { Cast } = $derived(getGlobalActions($t)); + const { Share, Offline, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(getAssetActions($t, asset)); // $: showEditorButton = @@ -137,8 +139,7 @@
- - + diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index ce204412f2..0fd96630eb 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -5,13 +5,14 @@ @@ -158,7 +161,7 @@ {/if}
- +
void this.initialize()); } - async initialize() { + private async initialize() { // this goes first to prevent multiple calls to initialize if (this.initialized) { return; diff --git a/web/src/lib/services/app.service.ts b/web/src/lib/services/app.service.ts new file mode 100644 index 0000000000..6597b2fb5e --- /dev/null +++ b/web/src/lib/services/app.service.ts @@ -0,0 +1,19 @@ +import { CastDestinationType, castManager } from '$lib/managers/cast-manager.svelte'; +import { GCastDestination } from '$lib/utils/cast/gcast-destination.svelte'; +import type { ActionItem } from '@immich/ui'; +import { mdiCast, mdiCastConnected } from '@mdi/js'; +import type { MessageFormatter } from 'svelte-i18n'; + +export const getGlobalActions = ($t: MessageFormatter) => { + const Cast: ActionItem = { + title: $t('cast'), + icon: castManager.isCasting ? mdiCastConnected : mdiCast, + color: castManager.isCasting ? 'primary' : 'secondary', + $if: () => + castManager.availableDestinations.length > 0 && + castManager.availableDestinations[0].type === CastDestinationType.GCAST, + onAction: () => void GCastDestination.showCastDialog(), + }; + + return { Cast }; +}; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9279893fbb..5d31bc2229 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,7 +1,7 @@ @@ -592,7 +595,7 @@ {#if viewMode === AlbumPageViewMode.VIEW} goto(backUrl)}> {#snippet trailing()} - + {#if isEditor} Date: Tue, 6 Jan 2026 19:46:25 -0600 Subject: [PATCH 08/17] fix: propagate iCloud Shared Album flag (#25060) * fix: propagate iCloud Shared Album flag * chore: add migration --- .../domain/services/local_sync.service.dart | 1 + .../entities/local_album.entity.dart | 1 + mobile/lib/utils/migration.dart | 25 ++++++++++++++++++- .../backup/drift_album_info_list_tile.dart | 12 +++++++-- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index c49ac49cce..1194331a6d 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -360,6 +360,7 @@ extension on Iterable { name: e.name, updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(), assetCount: e.assetCount, + isIosSharedAlbum: e.isCloud, ), ).toList(); } diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart index 707d3326a4..641a5359f6 100644 --- a/mobile/lib/infrastructure/entities/local_album.entity.dart +++ b/mobile/lib/infrastructure/entities/local_album.entity.dart @@ -33,6 +33,7 @@ extension LocalAlbumEntityDataHelper on LocalAlbumEntityData { assetCount: assetCount, backupSelection: backupSelection, linkedRemoteAlbumId: linkedRemoteAlbumId, + isIosSharedAlbum: isIosSharedAlbum, ); } } diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 35cdc7addf..30a9702b53 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -31,7 +31,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 19; +const int targetVersion = 20; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -86,6 +86,10 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { } } + if (version < 20 && Store.isBetaTimelineEnabled) { + await _syncLocalAlbumIsIosSharedAlbum(drift); + } + if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; @@ -258,6 +262,25 @@ Future _populateLocalAssetTime(Drift db) async { } } +Future _syncLocalAlbumIsIosSharedAlbum(Drift db) async { + try { + final nativeApi = NativeSyncApi(); + final albums = await nativeApi.getAlbums(); + await db.batch((batch) { + for (final album in albums) { + batch.update( + db.localAlbumEntity, + LocalAlbumEntityCompanion(isIosSharedAlbum: Value(album.isCloud)), + where: (t) => t.id.equals(album.id), + ); + } + }); + dPrint(() => "[MIGRATION] Successfully updated isIosSharedAlbum for ${albums.length} albums"); + } catch (error) { + dPrint(() => "[MIGRATION] Error while syncing local album isIosSharedAlbum: $error"); + } +} + Future migrateDeviceAssetToSqlite(Isar db, Drift drift) async { try { final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll(); diff --git a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart index 596e46d934..84128ddde2 100644 --- a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart @@ -4,6 +4,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -41,6 +42,13 @@ class DriftAlbumInfoListTile extends HookConsumerWidget { return Icon(Icons.circle, color: context.colorScheme.surfaceContainerHighest); } + Widget buildSubtitle() { + return Text( + album.isIosSharedAlbum ? '${album.assetCount} (iCloud Shared Album)' : album.assetCount.toString(), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ); + } + return GestureDetector( onDoubleTap: () { ref.watch(hapticFeedbackProvider.notifier).selectionClick(); @@ -73,8 +81,8 @@ class DriftAlbumInfoListTile extends HookConsumerWidget { } }, leading: buildIcon(), - title: Text(album.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - subtitle: Text(album.assetCount.toString()), + title: Text(album.name, style: context.textTheme.titleSmall), + subtitle: buildSubtitle(), trailing: IconButton( onPressed: () { context.pushRoute(LocalTimelineRoute(album: album)); From 225b0f9377053abaee8cb2554c966b26bb6a851d Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 7 Jan 2026 16:46:04 +0100 Subject: [PATCH 09/17] chore: use setup-uv action to install python (#25109) chore: update GitHub Actions workflow to use setup-uv action to install python --- .github/workflows/test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b00612b41b..ad9fd95b88 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -572,11 +572,8 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Install uv uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 - - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 - # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) with: python-version: 3.11 - #cache: 'uv' - name: Install dependencies run: | uv sync --extra cpu From 81f269e2a983cafc3c91fae7155cf1a5ae3aef41 Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 7 Jan 2026 18:19:43 +0100 Subject: [PATCH 10/17] fix(docs): Use full git clone in CI to enable accurate last update times (#25120) --- .github/workflows/docs-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 680cd0318c..3e55a13869 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -64,6 +64,7 @@ jobs: with: persist-credentials: false token: ${{ steps.token.outputs.token }} + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 From 78229baeabefa8393bfbb5331b1d1da99b896d6b Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 7 Jan 2026 15:17:12 -0500 Subject: [PATCH 11/17] feat: improve asset-viewer next/prev perf and standardize preloading behavior (#24422) Co-authored-by: Alex --- e2e/src/generators.ts | 1 - pnpm-lock.yaml | 10 - web/package.json | 1 - .../asset-viewer/asset-viewer.svelte | 136 ++++++------ .../asset-viewer/photo-viewer.spec.ts | 210 ------------------ .../asset-viewer/photo-viewer.svelte | 98 +++----- .../assets/thumbnail/image-thumbnail.svelte | 4 +- .../memory-page/memory-viewer.svelte | 6 +- .../individual-shared-viewer.svelte | 6 +- .../gallery-viewer/gallery-viewer.svelte | 16 +- .../lib/components/timeline/Timeline.svelte | 10 +- .../timeline/TimelineAssetViewer.svelte | 120 +++++++--- .../duplicates-compare-control.svelte | 9 +- web/src/lib/managers/PreloadManager.svelte.ts | 38 ++++ .../modals/ProfileImageCropperModal.svelte | 2 +- web/src/lib/stores/asset-viewing.store.ts | 10 +- web/src/lib/utils.spec.ts | 137 +++++++++++- web/src/lib/utils.ts | 39 +++- web/src/lib/utils/asset-utils.ts | 8 + web/src/lib/utils/invocationTracker.ts | 9 + web/src/lib/utils/sw-messaging.ts | 10 +- .../[[assetId=id]]/+page.svelte | 60 ++++- .../[[assetId=id]]/+page.svelte | 11 +- .../[[assetId=id]]/+page.svelte | 11 +- 24 files changed, 529 insertions(+), 433 deletions(-) delete mode 100644 web/src/lib/components/asset-viewer/photo-viewer.spec.ts create mode 100644 web/src/lib/managers/PreloadManager.svelte.ts diff --git a/e2e/src/generators.ts b/e2e/src/generators.ts index c87427ceab..5e4895d708 100644 --- a/e2e/src/generators.ts +++ b/e2e/src/generators.ts @@ -26,6 +26,5 @@ export const makeRandomImage = () => { if (!value) { throw new Error('Ran out of random asset data'); } - return value; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91285d6784..5048babbc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -761,9 +761,6 @@ importers: '@zoom-image/svelte': specifier: ^0.3.0 version: 0.3.8(svelte@5.46.1) - async-mutex: - specifier: ^0.5.0 - version: 0.5.0 dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -5620,9 +5617,6 @@ packages: async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} - async-mutex@0.5.0: - resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} - async@0.2.10: resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} @@ -18001,10 +17995,6 @@ snapshots: async-lock@1.4.1: {} - async-mutex@0.5.0: - dependencies: - tslib: 2.8.1 - async@0.2.10: {} async@3.2.6: {} diff --git a/web/package.json b/web/package.json index 52d06bb519..f05fc6dee7 100644 --- a/web/package.json +++ b/web/package.json @@ -39,7 +39,6 @@ "@types/geojson": "^7946.0.16", "@zoom-image/core": "^0.41.0", "@zoom-image/svelte": "^0.3.0", - "async-mutex": "^0.5.0", "dom-to-image": "^2.6.0", "fabric": "^6.5.4", "geo-coordinates-parser": "^1.7.4", diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 03571bbebf..19b9bd3555 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -10,18 +10,19 @@ import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; - import { websocketEvents } from '$lib/stores/websocket'; - import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils'; + import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; import { handleError } from '$lib/utils/handle-error'; + import { InvocationTracker } from '$lib/utils/invocationTracker'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; + import { preloadImageUrl } from '$lib/utils/sw-messaging'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetJobName, @@ -53,17 +54,22 @@ type HasAsset = boolean; + export type AssetCursor = { + current: AssetResponseDto; + nextAsset?: AssetResponseDto; + previousAsset?: AssetResponseDto; + }; + interface Props { - asset: AssetResponseDto; - preloadAssets?: TimelineAsset[]; + cursor: AssetCursor; showNavigation?: boolean; withStacked?: boolean; isShared?: boolean; - album?: AlbumResponseDto | null; - person?: PersonResponseDto | null; - preAction?: PreAction | undefined; - onAction?: OnAction | undefined; - onUndoDelete?: OnUndoDelete | undefined; + album?: AlbumResponseDto; + person?: PersonResponseDto; + preAction?: PreAction; + onAction?: OnAction; + onUndoDelete?: OnUndoDelete; onClose?: (asset: AssetResponseDto) => void; onNext: () => Promise; onPrevious: () => Promise; @@ -72,16 +78,15 @@ } let { - asset = $bindable(), - preloadAssets = $bindable([]), + cursor, showNavigation = true, withStacked = false, isShared = false, - album = null, - person = null, - preAction = undefined, - onAction = undefined, - onUndoDelete = undefined, + album, + person, + preAction, + onAction, + onUndoDelete, onClose, onNext, onPrevious, @@ -100,6 +105,7 @@ const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; + let asset = $derived(cursor.current); let appearsInAlbums: AlbumResponseDto[] = $state([]); let sharedLink = getSharedLink(); let previewStackedAsset: AssetResponseDto | undefined = $state(); @@ -131,7 +137,7 @@ untrack(() => { if (stack && stack?.assets.length > 1) { - preloadAssets.push(toTimelineAsset(stack.assets[1])); + preloadImageUrl(getAssetUrl({ asset: stack.assets[1] })); } }); }; @@ -146,16 +152,8 @@ } }; - const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => { - if (assetUpdate.id === asset.id) { - asset = assetUpdate; - } - }; - onMount(async () => { unsubscribes.push( - websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })), - websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })), slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { slideshowHistory.reset(); @@ -208,7 +206,9 @@ }); }; - const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => { + const tracker = new InvocationTracker(); + + const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -218,38 +218,37 @@ } e?.stopPropagation(); + preloadManager.cancel(asset); + if (tracker.isActive()) { + return; + } - let hasNext = false; + void tracker.invoke(async () => { + let hasNext = false; - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { - hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); - if (!hasNext) { - const asset = await onRandom(); - if (asset) { - slideshowHistory.queue(asset); - hasNext = true; + if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { + hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); + if (!hasNext) { + const asset = await onRandom(); + if (asset) { + slideshowHistory.queue(asset); + hasNext = true; + } + } + } else { + hasNext = order === 'previous' ? await onPrevious() : await onNext(); + } + + if ($slideshowState === SlideshowState.PlaySlideshow) { + if (hasNext) { + $restartSlideshowProgress = true; + } else { + await handleStopSlideshow(); } } - } else { - hasNext = order === 'previous' ? await onPrevious() : await onNext(); - } - - if ($slideshowState === SlideshowState.PlaySlideshow) { - if (hasNext) { - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); - } - } + }); }; - // const showEditorHandler = () => { - // if (isShowActivity) { - // isShowActivity = false; - // } - // isShowEditor = !isShowEditor; - // }; - const handleRunJob = async (name: AssetJobName) => { try { await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } }); @@ -362,12 +361,6 @@ let isFullScreen = $derived(fullscreenElement !== null); - $effect(() => { - if (asset) { - previewStackedAsset = undefined; - handlePromiseError(refreshStack()); - } - }); $effect(() => { if (album && !album.isActivityEnabled && activityManager.commentCount === 0) { assetViewerManager.closeActivityPanel(); @@ -379,13 +372,24 @@ } }); - // primarily, this is reactive on `asset` - $effect(() => { - handlePromiseError(handleGetAllAlbums()); + const refresh = async () => { + await refreshStack(); + await handleGetAllAlbums(); ocrManager.clear(); if (!sharedLink) { - handlePromiseError(ocrManager.getAssetOcr(asset.id)); + if (previewStackedAsset) { + await ocrManager.getAssetOcr(previewStackedAsset.id); + } + await ocrManager.getAssetOcr(asset.id); } + }; + + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + asset; + untrack(() => handlePromiseError(refresh())); + preloadManager.preload(cursor.nextAsset); + preloadManager.preload(cursor.previousAsset); }); @@ -449,8 +453,7 @@ navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} haveFadeTransition={false} @@ -495,8 +498,7 @@ navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} {sharedLink} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts deleted file mode 100644 index fd1a40e4db..0000000000 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { getAnimateMock } from '$lib/__mocks__/animate.mock'; -import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte'; -import * as utils from '$lib/utils'; -import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk'; -import { assetFactory } from '@test-data/factories/asset-factory'; -import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; -import { render } from '@testing-library/svelte'; -import type { MockInstance } from 'vitest'; - -class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -globalThis.ResizeObserver = ResizeObserver; - -vi.mock('$lib/utils', async (originalImport) => { - const meta = await originalImport(); - return { - ...meta, - getAssetOriginalUrl: vi.fn(), - getAssetThumbnailUrl: vi.fn(), - }; -}); - -describe('PhotoViewer component', () => { - let getAssetOriginalUrlSpy: MockInstance; - let getAssetThumbnailUrlSpy: MockInstance; - - beforeAll(() => { - getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl'); - getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl'); - - vi.stubGlobal('cast', { - framework: { - CastState: { - NO_DEVICES_AVAILABLE: 'NO_DEVICES_AVAILABLE', - }, - RemotePlayer: vi.fn().mockImplementation(() => ({})), - RemotePlayerEventType: { - ANY_CHANGE: 'anyChanged', - }, - RemotePlayerController: vi.fn().mockImplementation(() => ({ addEventListener: vi.fn() })), - CastContext: { - getInstance: vi.fn().mockImplementation(() => ({ setOptions: vi.fn(), addEventListener: vi.fn() })), - }, - CastContextEventType: { - SESSION_STATE_CHANGED: 'sessionstatechanged', - CAST_STATE_CHANGED: 'caststatechanged', - }, - }, - }); - vi.stubGlobal('chrome', { - cast: { media: { PlayerState: { IDLE: 'IDLE' } }, AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' } }, - }); - }); - - beforeEach(() => { - Element.prototype.animate = getAnimateMock(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('loads the thumbnail', () => { - const asset = assetFactory.build({ - originalPath: 'image.jpg', - originalMimeType: 'image/jpeg', - type: AssetTypeEnum.Image, - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('loads the thumbnail image for static gifs', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('loads the thumbnail image for static webp images', () => { - const asset = assetFactory.build({ - originalPath: 'image.webp', - originalMimeType: 'image/webp', - type: AssetTypeEnum.Image, - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('loads the original image for animated gifs', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); - }); - - it('loads the original image for animated webp images', () => { - const asset = assetFactory.build({ - originalPath: 'image.webp', - originalMimeType: 'image/webp', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); - }); - - it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - }); - const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); - render(PhotoViewer, { asset, sharedLink }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); - render(PhotoViewer, { asset, sharedLink }); - - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); - }); - - it('not loads original animated image when shared link download permission is false', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] }); - render(PhotoViewer, { asset, sharedLink }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('not loads original animated image when shared link showMetadata permission is false', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] }); - render(PhotoViewer, { asset, sharedLink }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); -}); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2607f6de79..baf46052be 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -6,32 +6,30 @@ import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import { assetViewerFadeDuration } from '$lib/constants'; import { castManager } from '$lib/managers/cast-manager.svelte'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; - import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; + import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils'; + import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; - import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; + import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; import { LoadingSpinner, toastManager } from '@immich/ui'; import { onDestroy, onMount } from 'svelte'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; + import type { AssetCursor } from './asset-viewer.svelte'; interface Props { - asset: AssetResponseDto; - preloadAssets?: TimelineAsset[] | undefined; + cursor: AssetCursor; element?: HTMLDivElement | undefined; haveFadeTransition?: boolean; sharedLink?: SharedLinkResponseDto | undefined; @@ -42,8 +40,7 @@ } let { - asset, - preloadAssets = undefined, + cursor, element = $bindable(), haveFadeTransition = true, sharedLink = undefined, @@ -54,8 +51,8 @@ }: Props = $props(); const { slideshowState, slideshowLook } = slideshowStore; + const asset = $derived(cursor.current); - let assetFileUrl: string = $state(''); let imageLoaded: boolean = $state(false); let originalImageLoaded: boolean = $state(false); let imageError: boolean = $state(false); @@ -82,25 +79,6 @@ let isOcrActive = $derived(ocrManager.showOverlay); - const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => { - for (const preloadAsset of preloadAssets || []) { - if (preloadAsset.isImage) { - let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash); - } - } - }; - - const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => { - if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { - return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); - } - - return targetSize === 'original' - ? getAssetOriginalUrl({ id, cacheKey }) - : getAssetThumbnailUrl({ id, size: targetSize, cacheKey }); - }; - copyImage = async () => { if (!canCopyImageToClipboard() || !$photoViewerImgElement) { return; @@ -155,23 +133,11 @@ } }; - // when true, will force loading of the original image - let forceUseOriginal: boolean = $derived( - (asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) || - $photoZoomState.currentZoom > 1, - ); - - const targetImageSize = $derived.by(() => { - if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) { - return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize; - } - - return AssetMediaSize.Preview; - }); + const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1)); $effect(() => { - if (assetFileUrl) { - void cast(assetFileUrl); + if (imageLoaderUrl) { + void cast(imageLoaderUrl); } }); @@ -191,7 +157,6 @@ const onload = () => { imageLoaded = true; - assetFileUrl = imageLoaderUrl; originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original'; }; @@ -199,27 +164,29 @@ imageError = imageLoaded = true; }; - $effect(() => { - preload(targetImageSize, preloadAssets); - }); - onMount(() => { - if (loader?.complete) { - onload(); - } - loader?.addEventListener('load', onload, { passive: true }); - loader?.addEventListener('error', onerror, { passive: true }); return () => { - loader?.removeEventListener('load', onload); - loader?.removeEventListener('error', onerror); - cancelImageUrl(imageLoaderUrl); + preloadManager.cancelPreloadUrl(imageLoaderUrl); }; }); - let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash)); + let imageLoaderUrl = $derived( + getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }), + ); let containerWidth = $state(0); let containerHeight = $state(0); + + let lastUrl: string | undefined; + + $effect(() => { + if (lastUrl && lastUrl !== imageLoaderUrl) { + imageLoaded = false; + originalImageLoaded = false; + imageError = false; + } + lastUrl = imageLoaderUrl; + }); {#if imageError} -
+
{/if} - - +
- {#if !imageLoaded}
@@ -258,7 +223,7 @@ > {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - import { cancelImageUrl } from '$lib/utils/sw-messaging'; + import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { Icon } from '@immich/ui'; import { mdiEyeOffOutline } from '@mdi/js'; import type { ActionReturn } from 'svelte/action'; @@ -60,7 +60,7 @@ onComplete?.(false); } return { - destroy: () => cancelImageUrl(url), + destroy: () => preloadManager.cancelPreloadUrl(url), }; } diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index cfe11e1026..34c6ee18db 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -32,7 +32,7 @@ import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; - import { AssetMediaSize, getAssetInfo } from '@immich/sdk'; + import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk'; import { IconButton, toastManager } from '@immich/ui'; import { mdiCardsOutline, @@ -67,7 +67,7 @@ let currentMemoryAssetFull = $derived.by(async () => current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, ); - let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []); + let currentTimelineAssets = $derived(current?.memory.assets || []); let isSaved = $derived(current?.memory.isSaved); let viewerHeight = $state(0); @@ -396,7 +396,7 @@

- {#if currentTimelineAssets.some(({ isVideo }) => isVideo)} + {#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
toTimelineAsset(a))); + let assets = $derived(sharedLink.assets); dragAndDropFilesStore.subscribe((value) => { if (value.isDragging && value.files.length > 0) { @@ -68,7 +68,7 @@ }; const handleSelectAll = () => { - assetInteraction.selectAssets(assets); + assetInteraction.selectAssets(assets.map((asset) => toTimelineAsset(asset))); }; const handleAction = async (action: Action) => { @@ -145,7 +145,7 @@ {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} Promise.resolve(false)} onNext={() => Promise.resolve(false)} diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index c695cafc76..f71944d20c 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -13,7 +13,7 @@ import { showDeleteModal } from '$lib/stores/preferences.store'; import { handlePromiseError } from '$lib/utils'; import { deleteAssets } from '$lib/utils/actions'; - import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; + import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; import { moveFocus } from '$lib/utils/focus-util'; import { handleError } from '$lib/utils/handle-error'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; @@ -27,7 +27,7 @@ interface Props { initialAssetId?: string; - assets: TimelineAsset[] | AssetResponseDto[]; + assets: AssetResponseDto[]; assetInteraction: AssetInteraction; disableAssetSelect?: boolean; showArchiveIcon?: boolean; @@ -229,7 +229,7 @@ isShowDeleteConfirmation = false; await deleteAssets( !(isTrashEnabled && !force), - (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]), + (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))), assetInteraction.selectedAssets, onReload, ); @@ -242,7 +242,7 @@ assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive, ); if (ids) { - assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[]; + assets = assets.filter((asset) => !ids.includes(asset.id)); deselectAllAssets(); } }; @@ -424,6 +424,12 @@ selectAssetCandidates(lastAssetMouseEvent); } }); + + const assetCursor = $derived({ + current: $viewingAsset, + nextAsset: getNextAsset(assets, $viewingAsset), + previousAsset: getPreviousAsset(assets, $viewingAsset), + }); {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} void; onEscape?: () => void; @@ -82,9 +82,9 @@ withStacked = false, showArchiveIcon = false, isShared = false, - album = null, + album, albumUsers = [], - person = null, + person, isShowDeleteConfirmation = $bindable(false), onSelect = () => {}, onEscape = () => {}, diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 9f8b5fe36b..29894308b2 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -1,24 +1,29 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} handleNavigateToAsset(assetCursor.previousAsset)} + onNext={() => handleNavigateToAsset(assetCursor.nextAsset)} onRandom={handleRandom} onClose={handleClose} /> diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 2afeebc559..16155d44c0 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -5,6 +5,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; + import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { navigate } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; @@ -102,6 +103,12 @@ const handleStack = () => { onStack(assets); }; + + const assetCursor = $derived({ + current: $viewingAsset, + nextAsset: getNextAsset(assets, $viewingAsset), + previousAsset: getPreviousAsset(assets, $viewingAsset), + }); 1} {onNext} {onPrevious} diff --git a/web/src/lib/managers/PreloadManager.svelte.ts b/web/src/lib/managers/PreloadManager.svelte.ts new file mode 100644 index 0000000000..a68c07d505 --- /dev/null +++ b/web/src/lib/managers/PreloadManager.svelte.ts @@ -0,0 +1,38 @@ +import { getAssetUrl } from '$lib/utils'; +import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging'; +import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; + +class PreloadManager { + preload(asset: AssetResponseDto | undefined) { + if (globalThis.isSecureContext) { + preloadImageUrl(getAssetUrl({ asset })); + return; + } + if (!asset || asset.type !== AssetTypeEnum.Image) { + return; + } + const img = new Image(); + const url = getAssetUrl({ asset }); + if (!url) { + return; + } + img.src = url; + } + + cancel(asset: AssetResponseDto | undefined) { + if (!globalThis.isSecureContext || !asset) { + return; + } + const url = getAssetUrl({ asset }); + cancelImageUrl(url); + } + + cancelPreloadUrl(url: string | undefined) { + if (!globalThis.isSecureContext) { + return; + } + cancelImageUrl(url); + } +} + +export const preloadManager = new PreloadManager(); diff --git a/web/src/lib/modals/ProfileImageCropperModal.svelte b/web/src/lib/modals/ProfileImageCropperModal.svelte index 7f7050f663..f7cc09f0ea 100644 --- a/web/src/lib/modals/ProfileImageCropperModal.svelte +++ b/web/src/lib/modals/ProfileImageCropperModal.svelte @@ -85,7 +85,7 @@
- +
diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index 99ee1b8c46..00e0224a0e 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,19 +1,15 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; -import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; -import { Mutex } from 'async-mutex'; import { readonly, writable } from 'svelte/store'; function createAssetViewingStore() { const viewingAssetStoreState = writable(); - const preloadAssets = writable([]); + const viewState = writable(false); - const viewingAssetMutex = new Mutex(); const gridScrollTarget = writable(); - const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => { - preloadAssets.set(assetsToPreload); + const setAsset = (asset: AssetResponseDto) => { viewingAssetStoreState.set(asset); viewState.set(true); }; @@ -30,8 +26,6 @@ function createAssetViewingStore() { return { asset: readonly(viewingAssetStoreState), - mutex: viewingAssetMutex, - preloadAssets: readonly(preloadAssets), isViewing: viewState, gridScrollTarget, setAsset, diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 169f42409c..3bc8665279 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -1,6 +1,141 @@ -import { getReleaseType } from '$lib/utils'; +import { getAssetUrl, getReleaseType } from '$lib/utils'; +import { AssetTypeEnum } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; describe('utils', () => { + describe(getAssetUrl.name, () => { + it('should return thumbnail URL for static images', () => { + const asset = assetFactory.build({ + originalPath: 'image.jpg', + originalMimeType: 'image/jpeg', + type: AssetTypeEnum.Image, + }); + + const url = getAssetUrl({ asset }); + + // Should return a thumbnail URL (contains /thumbnail) + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for static gifs', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for static webp images', () => { + const asset = assetFactory.build({ + originalPath: 'image.webp', + originalMimeType: 'image/webp', + type: AssetTypeEnum.Image, + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + + it('should return original URL for animated gifs', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + + const url = getAssetUrl({ asset }); + + // Should return original URL (contains /original) + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return original URL for animated webp images', () => { + const asset = assetFactory.build({ + originalPath: 'image.webp', + originalMimeType: 'image/webp', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + }); + const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); + + const url = getAssetUrl({ asset, sharedLink }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + + it('should return original URL for animated images in shared link with download and showMetadata permissions', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); + + const url = getAssetUrl({ asset, sharedLink }); + + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL (not original) for animated images when shared link download permission is false', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] }); + + const url = getAssetUrl({ asset, sharedLink }); + + expect(url).toContain('/thumbnail'); + expect(url).not.toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL (not original) for animated images when shared link showMetadata permission is false', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] }); + + const url = getAssetUrl({ asset, sharedLink }); + + expect(url).toContain('/thumbnail'); + expect(url).not.toContain('/original'); + expect(url).toContain(asset.id); + }); + }); + describe(getReleaseType.name, () => { it('should return "major" for major version changes', () => { expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major'); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 5ae025f59c..b89e1a68bb 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,10 +1,12 @@ import { defaultLang, langs, locales } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; -import { lang } from '$lib/stores/preferences.store'; +import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store'; +import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { AssetJobName, AssetMediaSize, + AssetTypeEnum, MemoryType, QueueName, finishOAuth, @@ -17,6 +19,7 @@ import { linkOAuthAccount, startOAuth, unlinkOAuthAccount, + type AssetResponseDto, type MemoryResponseDto, type PersonResponseDto, type ServerVersionResponseDto, @@ -191,6 +194,40 @@ const createUrl = (path: string, parameters?: Record) => { type AssetUrlOptions = { id: string; cacheKey?: string | null }; +export const getAssetUrl = ({ + asset, + sharedLink, + forceOriginal = false, +}: { + asset: AssetResponseDto | undefined; + sharedLink?: SharedLinkResponseDto; + forceOriginal?: boolean; +}) => { + if (!asset) { + return; + } + const id = asset.id; + const cacheKey = asset.thumbhash; + if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { + return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); + } + const targetSize = targetImageSize(asset, forceOriginal); + return targetSize === 'original' + ? getAssetOriginalUrl({ id, cacheKey }) + : getAssetThumbnailUrl({ id, size: targetSize, cacheKey }); +}; + +const forceUseOriginal = (asset: AssetResponseDto) => { + return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000'); +}; + +export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => { + if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) { + return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize; + } + return AssetMediaSize.Preview; +}; + export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => { if (typeof options === 'string') { options = { id: options }; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index aa96d56aec..c0e43f74b5 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -557,6 +557,14 @@ export const delay = async (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; +export const getNextAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => { + return currentAsset && assets[assets.indexOf(currentAsset) + 1]; +}; + +export const getPreviousAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => { + return currentAsset && assets[assets.indexOf(currentAsset) - 1]; +}; + export const canCopyImageToClipboard = (): boolean => { return !!(navigator.clipboard && globalThis.ClipboardItem); }; diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts index ebc97dfde0..7d42d8c613 100644 --- a/web/src/lib/utils/invocationTracker.ts +++ b/web/src/lib/utils/invocationTracker.ts @@ -50,4 +50,13 @@ export class InvocationTracker { isActive() { return this.invocationsStarted !== this.invocationsEnded; } + + async invoke(invocable: () => Promise) { + const invocation = this.startInvocation(); + try { + return await invocable(); + } finally { + invocation.endInvocation(); + } + } } diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 1a19d3c134..61cd1b8df0 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,8 +1,14 @@ const broadcast = new BroadcastChannel('immich'); -export function cancelImageUrl(url: string) { +export function cancelImageUrl(url: string | undefined | null) { + if (!url) { + return; + } broadcast.postMessage({ type: 'cancel', url }); } -export function preloadImageUrl(url: string) { +export function preloadImageUrl(url: string | undefined | null) { + if (!url) { + return; + } broadcast.postMessage({ type: 'preload', url }); } diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index fd443a6470..27dc10be57 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,15 +1,18 @@ {#if featureFlagsManager.value.map} @@ -85,7 +141,7 @@ {#if $showAssetViewer} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} onNext={navigateNext} onPrevious={navigatePrevious} 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 b58210187b..0cc30c2c0a 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 @@ -22,7 +22,7 @@ import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; - import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; + import type { Viewport } from '$lib/managers/timeline-manager/types'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { lang, locale } from '$lib/stores/preferences.store'; @@ -35,6 +35,7 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, + type AssetResponseDto, getPerson, getTagById, type MetadataSearchDto, @@ -58,7 +59,7 @@ let nextPage = $state(1); let searchResultAlbums: AlbumResponseDto[] = $state([]); - let searchResultAssets: TimelineAsset[] = $state([]); + let searchResultAssets: AssetResponseDto[] = $state([]); let isLoading = $state(true); let scrollY = $state(0); let scrollYHistory = 0; @@ -121,7 +122,7 @@ const onAssetDelete = (assetIds: string[]) => { const assetIdSet = new Set(assetIds); - searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id)); + searchResultAssets = searchResultAssets.filter((asset: AssetResponseDto) => !assetIdSet.has(asset.id)); }; const handleSetVisibility = (assetIds: string[]) => { @@ -130,7 +131,7 @@ }; const handleSelectAll = () => { - assetInteraction.selectAssets(searchResultAssets); + assetInteraction.selectAssets(searchResultAssets.map((asset) => toTimelineAsset(asset))); }; async function onSearchQueryUpdate() { @@ -162,7 +163,7 @@ : await searchAssets({ metadataSearchDto: searchDto }); searchResultAlbums.push(...albums.items); - searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset))); + searchResultAssets.push(...assets.items); nextPage = Number(assets.nextPage) || 0; } catch (error) { diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte index 06f075feb6..15f4b233eb 100644 --- a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -5,10 +5,11 @@ import Portal from '$lib/elements/Portal.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; + import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; import { navigate } from '$lib/utils/navigation'; + import type { AssetResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; - import type { AssetResponseDto } from '@immich/sdk'; interface Props { data: PageData; @@ -65,6 +66,12 @@ const onViewAsset = async (asset: AssetResponseDto) => { await navigate({ targetRoute: 'current', assetId: asset.id }); }; + + const assetCursor = $derived({ + current: $viewingAsset, + nextAsset: getNextAsset(assets, $viewingAsset), + previousAsset: getPreviousAsset(assets, $viewingAsset), + }); @@ -85,7 +92,7 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} {onNext} {onPrevious} From 5bb34926169e82f2035807b2a8e32fd7a9731fa6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 7 Jan 2026 16:21:19 -0500 Subject: [PATCH 12/17] refactor: favorite action (#25121) --- pnpm-lock.yaml | 10 ++-- web/package.json | 2 +- web/src/lib/components/ActionButton.svelte | 2 +- web/src/lib/components/TableButton.svelte | 2 +- .../components/asset-viewer/actions/action.ts | 2 - .../actions/favorite-action.svelte | 51 ---------------- .../asset-viewer/asset-viewer-nav-bar.svelte | 13 ++-- .../asset-viewer/asset-viewer.svelte | 26 ++++---- .../lib/components/timeline/Timeline.svelte | 8 +-- .../timeline/TimelineAssetViewer.svelte | 10 +--- web/src/lib/constants.ts | 2 - web/src/lib/managers/event-manager.svelte.ts | 2 + .../timeline-manager.svelte.ts | 13 ++++ web/src/lib/services/asset.service.ts | 60 ++++++++++++++++++- web/src/lib/utils.ts | 5 +- .../[[assetId=id]]/+page.svelte | 2 - web/src/routes/+layout.svelte | 3 +- 17 files changed, 111 insertions(+), 102 deletions(-) delete mode 100644 web/src/lib/components/asset-viewer/actions/favorite-action.svelte diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5048babbc6..d4ffc00639 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -726,8 +726,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.52.0 - version: 0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + specifier: ^0.53.3 + version: 0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -3075,8 +3075,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.52.0': - resolution: {integrity: sha512-ECQIE5qYNpe7Q5+hifIGUDaRQXBkPOp9dvZaHELWWzAGIhbwG+mUYwMpUgU2TO7fV5u8XU6nHyBuC055zApiWQ==} + '@immich/ui@0.53.3': + resolution: {integrity: sha512-Ax7ctU9KIZgET58+PoMQnf1XDOIH76Xa341TXDfLwF96F3fQZ/v4TA7Ycb6hmTwIYGU9arIgqGqQDbuuNxc2vA==} peerDependencies: svelte: ^5.0.0 @@ -15078,7 +15078,7 @@ snapshots: dependencies: svelte: 5.46.1 - '@immich/ui@0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)': + '@immich/ui@0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)': dependencies: '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1) '@internationalized/date': 3.10.0 diff --git a/web/package.json b/web/package.json index f05fc6dee7..ef48a8a92f 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.52.0", + "@immich/ui": "^0.53.3", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte index e0e7e1eff7..4d0e474389 100644 --- a/web/src/lib/components/ActionButton.svelte +++ b/web/src/lib/components/ActionButton.svelte @@ -9,6 +9,6 @@ const { title, icon, color = 'secondary', onAction } = $derived(action); -{#if action.$if?.() ?? true} +{#if icon && (action.$if?.() ?? true)} onAction(action)} /> {/if} diff --git a/web/src/lib/components/TableButton.svelte b/web/src/lib/components/TableButton.svelte index 844c4c0bf8..619d2f6c27 100644 --- a/web/src/lib/components/TableButton.svelte +++ b/web/src/lib/components/TableButton.svelte @@ -10,6 +10,6 @@ const { title, icon, onAction } = $derived(action); -{#if action.$if?.() ?? true} +{#if icon && (action.$if?.() ?? true)} onAction(action)} /> {/if} diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index df61b5d073..19cc5afa8d 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -5,8 +5,6 @@ import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackRespon type ActionMap = { [AssetAction.ARCHIVE]: { asset: TimelineAsset }; [AssetAction.UNARCHIVE]: { asset: TimelineAsset }; - [AssetAction.FAVORITE]: { asset: TimelineAsset }; - [AssetAction.UNFAVORITE]: { asset: TimelineAsset }; [AssetAction.TRASH]: { asset: TimelineAsset }; [AssetAction.DELETE]: { asset: TimelineAsset }; [AssetAction.RESTORE]: { asset: TimelineAsset }; diff --git a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte deleted file mode 100644 index ba23570d36..0000000000 --- a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 38ab066c82..4aa74c9fe5 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -8,7 +8,6 @@ import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; - import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte'; import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte'; @@ -28,7 +27,7 @@ import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { user } from '$lib/stores/user.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { getAssetJobName, getSharedLink } from '$lib/utils'; + import { getAssetJobName, getSharedLink, withoutIcons } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { toTimelineAsset } from '$lib/utils/timeline-util'; @@ -105,6 +104,7 @@ const Close: ActionItem = { title: $t('go_back'), + type: $t('assets'), icon: mdiArrowLeft, $if: () => !!onClose, onAction: () => onClose?.(), @@ -113,7 +113,9 @@ const { Cast } = $derived(getGlobalActions($t)); - const { Share, Offline, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(getAssetActions($t, asset)); + const { Share, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = $derived( + getAssetActions($t, asset), + ); // $: showEditorButton = // isOwner && @@ -128,7 +130,7 @@
+ + {#if isOwner} - {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 19b9bd3555..27fe0f8c74 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -350,15 +350,6 @@ selectedEditType = type; }; - const handleAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => { - if (oldAssetId !== asset.id) { - return; - } - - await new Promise((promise) => setTimeout(promise, 500)); - await goto(`${AppRoute.PHOTOS}/${newAssetId}`); - }; - let isFullScreen = $derived(fullscreenElement !== null); $effect(() => { @@ -391,9 +382,24 @@ preloadManager.preload(cursor.nextAsset); preloadManager.preload(cursor.previousAsset); }); + + const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => { + if (oldAssetId !== asset.id) { + return; + } + + await new Promise((promise) => setTimeout(promise, 500)); + await goto(`${AppRoute.PHOTOS}/${newAssetId}`); + }; + + const onAssetUpdate = (update: AssetResponseDto) => { + if (asset.id === update.id) { + asset = update; + } + }; - + diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index b8093da90d..f2ef209ad5 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -39,13 +39,7 @@ timelineManager?: TimelineManager; options?: TimelineManagerOptions; assetInteraction: AssetInteraction; - removeAction?: - | AssetAction.UNARCHIVE - | AssetAction.ARCHIVE - | AssetAction.FAVORITE - | AssetAction.UNFAVORITE - | AssetAction.SET_VISIBILITY_TIMELINE - | null; + removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null; withStacked?: boolean; showArchiveIcon?: boolean; isShared?: boolean; diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 29894308b2..4d88700bf5 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -25,13 +25,7 @@ album?: AlbumResponseDto; person?: PersonResponseDto; - removeAction?: - | AssetAction.UNARCHIVE - | AssetAction.ARCHIVE - | AssetAction.FAVORITE - | AssetAction.UNFAVORITE - | AssetAction.SET_VISIBILITY_TIMELINE - | null; + removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null; } let { @@ -141,8 +135,6 @@ switch (action.type) { case AssetAction.ARCHIVE: case AssetAction.UNARCHIVE: - case AssetAction.FAVORITE: - case AssetAction.UNFAVORITE: case AssetAction.ADD: { timelineManager.upsertAssets([action.asset]); break; diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 72056008cd..fca52c301f 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -3,8 +3,6 @@ export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12 export enum AssetAction { ARCHIVE = 'archive', UNARCHIVE = 'unarchive', - FAVORITE = 'favorite', - UNFAVORITE = 'unfavorite', TRASH = 'trash', DELETE = 'delete', RESTORE = 'restore', diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 6038c3c3f0..f9fa87e0cf 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -3,6 +3,7 @@ import type { ReleaseEvent } from '$lib/types'; import type { AlbumResponseDto, ApiKeyResponseDto, + AssetResponseDto, LibraryResponseDto, LoginResponseDto, QueueResponseDto, @@ -24,6 +25,7 @@ export type Events = { ApiKeyUpdate: [ApiKeyResponseDto]; ApiKeyDelete: [ApiKeyResponseDto]; + AssetUpdate: [AssetResponseDto]; AssetReplace: [{ oldAssetId: string; newAssetId: string }]; AlbumUpdate: [AlbumResponseDto]; diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index b0dc30dc6e..7625659e94 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -1,5 +1,6 @@ import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; @@ -93,6 +94,7 @@ export class TimelineManager extends VirtualScrollManager { #updatingIntersections = false; #scrollableElement: HTMLElement | undefined = $state(); #showAssetOwners = new PersistedLocalStorage('album-show-asset-owners', false); + #unsubscribes: Array<() => void> = []; get showAssetOwners() { return this.#showAssetOwners.current; @@ -108,6 +110,12 @@ export class TimelineManager extends VirtualScrollManager { constructor() { super(); + + const onAssetUpdate = (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]); + + eventManager.on('AssetUpdate', onAssetUpdate); + + this.#unsubscribes.push(() => eventManager.off('AssetUpdate', onAssetUpdate)); } override get scrollTop(): number { @@ -269,6 +277,11 @@ export class TimelineManager extends VirtualScrollManager { public override destroy() { this.disconnect(); this.isInitialized = false; + + for (const unsubscribe of this.#unsubscribes) { + unsubscribe(); + } + super.destroy(); } diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index de5223db23..d22d8ab241 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -3,10 +3,14 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import { user as authUser } from '$lib/stores/user.store'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; -import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk'; -import { modalManager, type ActionItem } from '@immich/ui'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { AssetVisibility, copyAsset, deleteAssets, updateAsset, type AssetResponseDto } from '@immich/sdk'; +import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { mdiAlertOutline, + mdiHeart, + mdiHeartOutline, mdiInformationOutline, mdiMotionPauseOutline, mdiMotionPlayOutline, @@ -16,9 +20,13 @@ import type { MessageFormatter } from 'svelte-i18n'; import { get } from 'svelte/store'; export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => { + const currentAuthUser = get(authUser); + const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId); + const Share: ActionItem = { title: $t('share'), icon: mdiShareVariantOutline, + type: $t('assets'), $if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked), onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }), }; @@ -26,6 +34,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = const PlayMotionPhoto: ActionItem = { title: $t('play_motion_photo'), icon: mdiMotionPlayOutline, + type: $t('assets'), $if: () => !!asset.livePhotoVideoId && !assetViewerManager.isPlayingMotionPhoto, onAction: () => { assetViewerManager.isPlayingMotionPhoto = true; @@ -35,15 +44,35 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = const StopMotionPhoto: ActionItem = { title: $t('stop_motion_photo'), icon: mdiMotionPauseOutline, + type: $t('assets'), $if: () => !!asset.livePhotoVideoId && assetViewerManager.isPlayingMotionPhoto, onAction: () => { assetViewerManager.isPlayingMotionPhoto = false; }, }; + const Favorite: ActionItem = { + title: $t('to_favorite'), + icon: mdiHeartOutline, + type: $t('assets'), + $if: () => isOwner && !asset.isFavorite, + onAction: () => handleFavorite(asset), + shortcuts: [{ key: 'f' }], + }; + + const Unfavorite: ActionItem = { + title: $t('unfavorite'), + icon: mdiHeart, + type: $t('assets'), + $if: () => isOwner && asset.isFavorite, + onAction: () => handleUnfavorite(asset), + shortcuts: [{ key: 'f' }], + }; + const Offline: ActionItem = { title: $t('asset_offline'), icon: mdiAlertOutline, + type: $t('assets'), color: 'danger', $if: () => !!asset.isOffline, onAction: () => assetViewerManager.toggleDetailPanel(), @@ -52,12 +81,37 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = const Info: ActionItem = { title: $t('info'), icon: mdiInformationOutline, + type: $t('assets'), $if: () => asset.hasMetadata, onAction: () => assetViewerManager.toggleDetailPanel(), shortcuts: [{ key: 'i' }], }; - return { Share, PlayMotionPhoto, StopMotionPhoto, Offline, Info }; + return { Share, Offline, Info, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto }; +}; + +const handleFavorite = async (asset: AssetResponseDto) => { + const $t = await getFormatter(); + + try { + const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } }); + toastManager.success($t('added_to_favorites')); + eventManager.emit('AssetUpdate', response); + } catch (error) { + handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } })); + } +}; + +const handleUnfavorite = async (asset: AssetResponseDto) => { + const $t = await getFormatter(); + + try { + const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } }); + toastManager.success($t('removed_from_favorites')); + eventManager.emit('AssetUpdate', response); + } catch (error) { + handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } })); + } }; export const handleReplaceAsset = async (oldAssetId: string) => { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index b89e1a68bb..0437b05700 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -26,7 +26,7 @@ import { type SharedLinkResponseDto, type UserResponseDto, } from '@immich/sdk'; -import { toastManager } from '@immich/ui'; +import { toastManager, type ActionItem } from '@immich/ui'; import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js'; import { init, register, t } from 'svelte-i18n'; import { derived, get } from 'svelte/store'; @@ -440,3 +440,6 @@ export const getReleaseType = ( }; export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`; + +export const withoutIcons = (actions: ActionItem[]): ActionItem[] => + actions.map((action) => ({ ...action, icon: undefined })); diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 781dc80ec8..d33c5e7474 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -16,7 +16,6 @@ import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; - import { AssetAction } from '$lib/constants'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; @@ -55,7 +54,6 @@ bind:timelineManager {options} {assetInteraction} - removeAction={AssetAction.UNFAVORITE} onEscape={handleEscape} > {#snippet empty()} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d921392512..1c7a190b08 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -145,7 +145,6 @@ icon: mdiThemeLightDark, onAction: () => themeManager.toggleTheme(), shortcuts: { shift: true, key: 't' }, - isGlobal: true, }, ]; @@ -181,7 +180,7 @@ icon: mdiServer, onAction: () => goto(AppRoute.ADMIN_STATS), }, - ].map((route) => ({ ...route, type: $t('page'), isGlobal: true, $if: () => $user?.isAdmin })); + ].map((route) => ({ ...route, type: $t('page'), $if: () => $user?.isAdmin })); const commands = $derived([...userCommands, ...adminCommands]); From ef4aec73987203151b4cc07a68d7139f8ae6bc85 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 7 Jan 2026 22:49:04 +0100 Subject: [PATCH 13/17] chore: refactor ErrorLayout (#25094) * chore: refactor ErrorLayout * Align links to top --- .../lib/components/layouts/ErrorLayout.svelte | 139 ++++++++---------- 1 file changed, 61 insertions(+), 78 deletions(-) diff --git a/web/src/lib/components/layouts/ErrorLayout.svelte b/web/src/lib/components/layouts/ErrorLayout.svelte index 1df1dbf422..f121684236 100644 --- a/web/src/lib/components/layouts/ErrorLayout.svelte +++ b/web/src/lib/components/layouts/ErrorLayout.svelte @@ -1,7 +1,19 @@ -
+
- + - +
-
-
-
-
-
-

- 🚨 {$t('error_title')} -

-
- handleCopy()} - /> -
-
+
+
+ + + + + {$t('error_title')} + + + -
+ + {error?.message} (HTTP {error?.code}) + {#if error?.stack} + +
{error.stack}
+ {/if} +
-
-
-

{error?.message} ({error?.code})

- {#if error?.stack} - -
{error?.stack || 'No stack'}
- {/if} -
-
- -
- - -
-
+ + + + + {$t('get_help')} + + + + + + {$t('read_changelog')} + + + + + + {$t('check_logs')} + + + +
From 4f803832adb6c6fd8aeb65cdc42480e349f7ad81 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 7 Jan 2026 17:01:20 -0500 Subject: [PATCH 14/17] refactor: download action (#25124) --- web/src/lib/components/ActionButton.svelte | 3 +- web/src/lib/components/ActionMenuItem.svelte | 16 ++++ .../actions/download-action.svelte | 35 --------- .../asset-viewer/asset-viewer-nav-bar.svelte | 21 ++--- .../context-menu/menu-option.svelte | 6 +- .../timeline/actions/DownloadAction.svelte | 5 +- web/src/lib/services/asset.service.ts | 77 ++++++++++++++++++- web/src/lib/utils.ts | 4 +- web/src/lib/utils/asset-utils.ts | 45 +---------- 9 files changed, 109 insertions(+), 103 deletions(-) create mode 100644 web/src/lib/components/ActionMenuItem.svelte delete mode 100644 web/src/lib/components/asset-viewer/actions/download-action.svelte diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte index 4d0e474389..ae8d1199e0 100644 --- a/web/src/lib/components/ActionButton.svelte +++ b/web/src/lib/components/ActionButton.svelte @@ -1,4 +1,5 @@ -{#if icon && (action.$if?.() ?? true)} +{#if icon && isEnabled(action)} onAction(action)} /> {/if} diff --git a/web/src/lib/components/ActionMenuItem.svelte b/web/src/lib/components/ActionMenuItem.svelte new file mode 100644 index 0000000000..d50d50bf0b --- /dev/null +++ b/web/src/lib/components/ActionMenuItem.svelte @@ -0,0 +1,16 @@ + + +{#if icon && isEnabled(action)} + onAction(action)} /> +{/if} diff --git a/web/src/lib/components/asset-viewer/actions/download-action.svelte b/web/src/lib/components/asset-viewer/actions/download-action.svelte deleted file mode 100644 index f790569703..0000000000 --- a/web/src/lib/components/asset-viewer/actions/download-action.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - -{#if !menuItem} - -{:else} - -{/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 4aa74c9fe5..60bde6e114 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -2,12 +2,12 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import ActionButton from '$lib/components/ActionButton.svelte'; + import ActionMenuItem from '$lib/components/ActionMenuItem.svelte'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; - import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte'; import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte'; @@ -27,7 +27,7 @@ import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { user } from '$lib/stores/user.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { getAssetJobName, getSharedLink, withoutIcons } from '$lib/utils'; + import { getAssetJobName, withoutIcons } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { toTimelineAsset } from '$lib/utils/timeline-util'; @@ -96,9 +96,7 @@ setPlayOriginalVideo, }: Props = $props(); - const sharedLink = getSharedLink(); let isOwner = $derived($user && asset.ownerId === $user?.id); - let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); let isLocked = $derived(asset.visibility === AssetVisibility.Locked); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); @@ -113,9 +111,8 @@ const { Cast } = $derived(getGlobalActions($t)); - const { Share, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = $derived( - getAssetActions($t, asset), - ); + const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = + $derived(getAssetActions($t, asset)); // $: showEditorButton = // isOwner && @@ -169,10 +166,7 @@ /> {/if} - {#if !isOwner && showDownloadButton} - - {/if} - + @@ -188,9 +182,8 @@ {#if showSlideshow && !isLocked} {/if} - {#if showDownloadButton} - - {/if} + + {#if !isLocked} {#if asset.isTrashed} diff --git a/web/src/lib/components/shared-components/context-menu/menu-option.svelte b/web/src/lib/components/shared-components/context-menu/menu-option.svelte index 95b4b9ad43..dc5a2d7c0f 100644 --- a/web/src/lib/components/shared-components/context-menu/menu-option.svelte +++ b/web/src/lib/components/shared-components/context-menu/menu-option.svelte @@ -3,12 +3,12 @@ import { shortcut as bindShortcut, shortcutLabel as computeShortcutLabel } from '$lib/actions/shortcut'; import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; import { generateId } from '$lib/utils/generate-id'; - import { Icon } from '@immich/ui'; + import { Icon, type IconLike } from '@immich/ui'; interface Props { text: string; subtitle?: string; - icon?: string; + icon?: IconLike; activeColor?: string; textColor?: string; onClick: () => void; @@ -19,7 +19,7 @@ let { text, subtitle = '', - icon = '', + icon, activeColor = 'bg-slate-300', textColor = 'text-immich-fg dark:text-immich-dark-bg', onClick, diff --git a/web/src/lib/components/timeline/actions/DownloadAction.svelte b/web/src/lib/components/timeline/actions/DownloadAction.svelte index 29f2bab610..b1b1640798 100644 --- a/web/src/lib/components/timeline/actions/DownloadAction.svelte +++ b/web/src/lib/components/timeline/actions/DownloadAction.svelte @@ -3,7 +3,8 @@ import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import { downloadArchive, downloadFile } from '$lib/utils/asset-utils'; + import { handleDownloadAsset } from '$lib/services/asset.service'; + import { downloadArchive } from '$lib/utils/asset-utils'; import { getAssetInfo } from '@immich/sdk'; import { IconButton } from '@immich/ui'; import { mdiDownload } from '@mdi/js'; @@ -24,7 +25,7 @@ if (assets.length === 1) { clearSelect(); let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id }); - await downloadFile(asset); + await handleDownloadAsset(asset); return; } diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index d22d8ab241..81b74e51e2 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -1,14 +1,27 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; -import { user as authUser } from '$lib/stores/user.store'; +import { user as authUser, preferences } from '$lib/stores/user.store'; +import { getSharedLink, sleep } from '$lib/utils'; +import { downloadUrl } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; -import { AssetVisibility, copyAsset, deleteAssets, updateAsset, type AssetResponseDto } from '@immich/sdk'; +import { asQueryString } from '$lib/utils/shared-links'; +import { + AssetVisibility, + copyAsset, + deleteAssets, + getAssetInfo, + getBaseUrl, + updateAsset, + type AssetResponseDto, +} from '@immich/sdk'; import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { mdiAlertOutline, + mdiDownload, mdiHeart, mdiHeartOutline, mdiInformationOutline, @@ -20,6 +33,7 @@ import type { MessageFormatter } from 'svelte-i18n'; import { get } from 'svelte/store'; export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => { + const sharedLink = getSharedLink(); const currentAuthUser = get(authUser); const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId); @@ -31,6 +45,20 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }), }; + const Download: ActionItem = { + title: $t('download'), + icon: mdiDownload, + shortcuts: { key: 'd', shift: true }, + type: $t('assets'), + $if: () => !!currentAuthUser, + onAction: () => handleDownloadAsset(asset), + }; + + const SharedLinkDownload: ActionItem = { + ...Download, + $if: () => !currentAuthUser && sharedLink && sharedLink.allowDownload, + }; + const PlayMotionPhoto: ActionItem = { title: $t('play_motion_photo'), icon: mdiMotionPlayOutline, @@ -87,7 +115,50 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = shortcuts: [{ key: 'i' }], }; - return { Share, Offline, Info, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto }; + return { Share, Download, SharedLinkDownload, Offline, Info, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto }; +}; + +export const handleDownloadAsset = async (asset: AssetResponseDto) => { + const $t = await getFormatter(); + + const assets = [ + { + filename: asset.originalFileName, + id: asset.id, + size: asset.exifInfo?.fileSizeInByte || 0, + }, + ]; + + const isAndroidMotionVideo = (asset: AssetResponseDto) => { + return asset.originalPath.includes('encoded-video'); + }; + + if (asset.livePhotoVideoId) { + const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId }); + if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) { + assets.push({ + filename: motionAsset.originalFileName, + id: asset.livePhotoVideoId, + size: motionAsset.exifInfo?.fileSizeInByte || 0, + }); + } + } + + const queryParams = asQueryString(authManager.params); + + for (const [i, { filename, id }] of assets.entries()) { + if (i !== 0) { + // play nice with Safari + await sleep(500); + } + + try { + toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } })); + downloadUrl(getBaseUrl() + `/assets/${id}/original` + (queryParams ? `?${queryParams}` : ''), filename); + } catch (error) { + handleError(error, $t('errors.error_downloading', { values: { filename } })); + } + } }; const handleFavorite = async (asset: AssetResponseDto) => { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 0437b05700..c640fa31bb 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -26,7 +26,7 @@ import { type SharedLinkResponseDto, type UserResponseDto, } from '@immich/sdk'; -import { toastManager, type ActionItem } from '@immich/ui'; +import { toastManager, type ActionItem, type IfLike } from '@immich/ui'; import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js'; import { init, register, t } from 'svelte-i18n'; import { derived, get } from 'svelte/store'; @@ -443,3 +443,5 @@ export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) export const withoutIcons = (actions: ActionItem[]): ActionItem[] => actions.map((action) => ({ ...action, icon: undefined })); + +export const isEnabled = ({ $if }: IfLike) => $if?.() ?? true; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index c0e43f74b5..9d69653439 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -9,7 +9,7 @@ import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { preferences } from '$lib/stores/user.store'; -import { downloadRequest, sleep, withError } from '$lib/utils'; +import { downloadRequest, withError } from '$lib/utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; @@ -23,7 +23,6 @@ import { createStack, deleteAssets, deleteStacks, - getAssetInfo, getBaseUrl, getDownloadInfo, getStack, @@ -232,48 +231,6 @@ export const downloadArchive = async (fileName: string, options: Omit { - const $t = get(t); - const assets = [ - { - filename: asset.originalFileName, - id: asset.id, - size: asset.exifInfo?.fileSizeInByte || 0, - }, - ]; - - const isAndroidMotionVideo = (asset: AssetResponseDto) => { - return asset.originalPath.includes('encoded-video'); - }; - - if (asset.livePhotoVideoId) { - const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId }); - if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) { - assets.push({ - filename: motionAsset.originalFileName, - id: asset.livePhotoVideoId, - size: motionAsset.exifInfo?.fileSizeInByte || 0, - }); - } - } - - const queryParams = asQueryString(authManager.params); - - for (const [i, { filename, id }] of assets.entries()) { - if (i !== 0) { - // play nice with Safari - await sleep(500); - } - - try { - toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } })); - downloadUrl(getBaseUrl() + `/assets/${id}/original` + (queryParams ? `?${queryParams}` : ''), filename); - } catch (error) { - handleError(error, $t('errors.error_downloading', { values: { filename } })); - } - } -}; - /** * Returns the lowercase filename extension without a dot (.) and * an empty string when not found. From 0a9f1a3cbfb464905a74ee0a6095e6b1e8ed204e Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 7 Jan 2026 19:10:29 -0500 Subject: [PATCH 15/17] feat: cache asset info for prev/next navigation (#24482) Co-authored-by: Alex --- .../timeline/TimelineAssetViewer.svelte | 12 +++- .../lib/managers/AssetCacheManager.svelte.ts | 60 +++++++++++++++++++ web/src/lib/utils/navigation.ts | 11 ++-- 3 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/managers/AssetCacheManager.svelte.ts diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 4d88700bf5..8500345df4 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -2,7 +2,7 @@ import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte'; import { AssetAction } from '$lib/constants'; - + import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; @@ -13,7 +13,7 @@ import { navigate } from '$lib/utils/navigation'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk'; - import { onMount, untrack } from 'svelte'; + import { onDestroy, onMount, untrack } from 'svelte'; let { asset: viewingAsset, gridScrollTarget } = assetViewingStore; @@ -196,6 +196,9 @@ await navigate({ targetRoute: 'current', assetId: restoredAsset.id }); } }; + onDestroy(() => { + assetCacheManager.invalidate(); + }); const onAssetUpdate = ({ asset }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => { if (asset.id === assetCursor.current.id) { void loadCloseAssets(asset); @@ -222,7 +225,10 @@ {album} {person} preAction={handlePreAction} - onAction={handleAction} + onAction={(action) => { + handleAction(action); + assetCacheManager.invalidate(); + }} onUndoDelete={handleUndoDelete} onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)} onNext={() => handleNavigateToAsset(assetCursor.nextAsset)} diff --git a/web/src/lib/managers/AssetCacheManager.svelte.ts b/web/src/lib/managers/AssetCacheManager.svelte.ts new file mode 100644 index 0000000000..0b5e697683 --- /dev/null +++ b/web/src/lib/managers/AssetCacheManager.svelte.ts @@ -0,0 +1,60 @@ +import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk'; + +const defaultSerializer = (params: K) => JSON.stringify(params); + +class AsyncCache { + #cache = new Map(); + + async getOrFetch( + params: K, + fetcher: (params: K) => Promise, + keySerializer: (params: K) => string = defaultSerializer, + updateCache: boolean, + ): Promise { + const cacheKey = keySerializer(params); + + const cached = this.#cache.get(cacheKey); + if (cached) { + return cached; + } + + const value = await fetcher(params); + if (value && updateCache) { + this.#cache.set(cacheKey, value); + } + + return value; + } + + clear() { + this.#cache.clear(); + } +} + +class AssetCacheManager { + #assetCache = new AsyncCache(); + #ocrCache = new AsyncCache(); + + async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) { + return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache); + } + + async getAssetOcr(id: string) { + return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id, true); + } + + clearAssetCache() { + this.#assetCache.clear(); + } + + clearOcrCache() { + this.#ocrCache.clear(); + } + + invalidate() { + this.clearAssetCache(); + this.clearOcrCache(); + } +} + +export const assetCacheManager = new AssetCacheManager(); diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index daf1d04ed5..b6c0cad616 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -1,8 +1,8 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; +import type { RouteId } from '$app/types'; import { AppRoute } from '$lib/constants'; -import { getAssetInfo } from '@immich/sdk'; -import type { NavigationTarget } from '@sveltejs/kit'; +import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { get } from 'svelte/store'; export type AssetGridRouteSearchParams = { @@ -20,11 +20,12 @@ export const isAlbumsRoute = (route?: string | null) => !!route?.startsWith('/(u export const isPeopleRoute = (route?: string | null) => !!route?.startsWith('/(user)/people/[personId]'); export const isLockedFolderRoute = (route?: string | null) => !!route?.startsWith('/(user)/locked'); -export const isAssetViewerRoute = (target?: NavigationTarget | null) => - !!(target?.route.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {})); +export const isAssetViewerRoute = ( + target?: { route?: { id?: RouteId | null }; params?: Record | null } | null, +) => !!(target?.route?.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {})); export function getAssetInfoFromParam({ assetId, slug, key }: { assetId?: string; key?: string; slug?: string }) { - return assetId ? getAssetInfo({ id: assetId, slug, key }) : undefined; + return assetId ? assetCacheManager.getAsset({ id: assetId, slug, key }, false) : undefined; } function currentUrlWithoutAsset() { From 1d6a9f6e80342922aff04ea241fc9791c2113f8f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 7 Jan 2026 20:55:28 -0600 Subject: [PATCH 16/17] feat: free up space (#24999) * feat(server): Support camera `make`, `model`, and `lensModel` in Storage Template (#24650) * add support for make, model, lensModel in storage template * no pkg lock * Apply suggestion from @danieldietzler Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * query and formatting --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * wip: copy-writing * feat: cutoff date preset options and filter options * fix: don't include iCloud Shared Album * chore: message about excluding shared album assets * feat: show preview in a separate page * feat: show clean up hint modal after success deletion * pr feedback * pr feedback * pr feedback --------- Co-authored-by: Rahul Kumar Saini Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- i18n/en.json | 28 + mobile/lib/constants/enums.dart | 4 + .../lib/domain/services/timeline.service.dart | 3 + .../repositories/local_asset.repository.dart | 46 ++ .../repositories/timeline.repository.dart | 18 + mobile/lib/pages/common/settings.page.dart | 3 + .../pages/cleanup_preview.page.dart | 42 ++ .../widgets/timeline/timeline.widget.dart | 14 + mobile/lib/providers/cleanup.provider.dart | 106 +++ mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 37 + mobile/lib/services/cleanup.service.dart | 45 ++ .../settings/free_up_space_settings.dart | 702 ++++++++++++++++++ .../local_asset_repository_test.dart | 438 +++++++++++ 14 files changed, 1488 insertions(+) create mode 100644 mobile/lib/presentation/pages/cleanup_preview.page.dart create mode 100644 mobile/lib/providers/cleanup.provider.dart create mode 100644 mobile/lib/services/cleanup.service.dart create mode 100644 mobile/lib/widgets/settings/free_up_space_settings.dart create mode 100644 mobile/test/infrastructure/repositories/local_asset_repository_test.dart diff --git a/i18n/en.json b/i18n/en.json index 529ab6dfdb..13fc965b65 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -734,6 +734,18 @@ "checksum": "Checksum", "choose_matching_people_to_merge": "Choose matching people to merge", "city": "City", + "cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?", + "cleanup_confirm_prompt_title": "Remove from this device?", + "cleanup_deleted_assets": "Moved {count} assets to device trash", + "cleanup_deleting": "Moving to trash...", + "cleanup_filter_description": "Choose which types of assets to remove in the cleanup", + "cleanup_found_assets": "Found {count} backed up assets", + "cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan", + "cleanup_no_assets_found": "No backed up assets found matching your criteria", + "cleanup_preview_title": "Assets to remove ({count})", + "cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options", + "cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device", + "cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash", "clear": "Clear", "clear_all": "Clear all", "clear_all_recent_searches": "Clear all recent searches", @@ -823,9 +835,13 @@ "current_device": "Current device", "current_pin_code": "Current PIN code", "current_server_address": "Current server address", + "custom_date": "Custom date", "custom_locale": "Custom Locale", "custom_locale_description": "Format dates and numbers based on the language and the region", "custom_url": "Custom URL", + "cutoff_date_description": "Remove photos and videos older than", + "cutoff_day": "{count, plural, one {day} other {days}}", + "cutoff_year": "{count, plural, one {year} other {years}}", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "dark": "Dark", @@ -1148,6 +1164,7 @@ "filetype": "Filetype", "filter": "Filter", "filter_description": "Conditions to filter the target assets", + "filter_options": "Filter options", "filter_people": "Filter people", "filter_places": "Filter places", "filters": "Filters", @@ -1160,6 +1177,9 @@ "folders_feature_description": "Browsing the folder view for the photos and videos on the file system", "forgot_pin_code_question": "Forgot your PIN?", "forward": "Forward", + "free_up_space": "Free Up Space", + "free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe", + "free_up_space_settings_subtitle": "Free up device storage", "full_path": "Full path: {path}", "gcast_enabled": "Google Cast", "gcast_enabled_description": "This feature loads external resources from Google in order to work.", @@ -1276,6 +1296,8 @@ "json_error": "JSON error", "keep": "Keep", "keep_all": "Keep All", + "keep_favorites": "Keep favorites", + "keep_favorites_description": "Favorite assets will not be deleted from your device", "keep_this_delete_others": "Keep this, delete others", "kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "Keyboard shortcuts", @@ -1446,6 +1468,7 @@ "move_down": "Move down", "move_off_locked_folder": "Move out of locked folder", "move_to": "Move to", + "move_to_device_trash": "Move to device trash", "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", @@ -1628,6 +1651,7 @@ "photos_and_videos": "Photos & Videos", "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}", "photos_from_previous_years": "Photos from previous years", + "photos_only": "Photos only", "pick_a_location": "Pick a location", "pick_custom_range": "Custom range", "pick_date_range": "Select a date range", @@ -1808,9 +1832,11 @@ "saved_settings": "Saved settings", "say_something": "Say something", "scaffold_body_error_occurred": "Error occurred", + "scan": "Scan", "scan_all_libraries": "Scan All Libraries", "scan_library": "Scan", "scan_settings": "Scan Settings", + "scanning": "Scanning", "scanning_for_album": "Scanning for album...", "search": "Search", "search_albums": "Search albums", @@ -1882,6 +1908,7 @@ "select_all_in": "Select all in {group}", "select_avatar_color": "Select avatar color", "select_count": "{count, plural, one {Select #} other {Select #}}", + "select_cutoff_date": "Select cutoff date", "select_face": "Select face", "select_featured_photo": "Select featured photo", "select_from_computer": "Select from computer", @@ -2250,6 +2277,7 @@ "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.", "videos": "Videos", "videos_count": "{count, plural, one {# Video} other {# Videos}}", + "videos_only": "Videos only", "view": "View", "view_album": "View Album", "view_all": "View All", diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 91ca50a2c0..c4505137d2 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -7,3 +7,7 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked } enum SortUserBy { id } enum ActionSource { timeline, viewer } + +enum CleanupStep { selectDate, filterOptions, scan, delete } + +enum AssetFilterType { all, photosOnly, videosOnly } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 96630f1eba..e866a965c4 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -79,6 +79,9 @@ class TimelineFactory { TimelineService fromAssets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssets(assets, type)); + TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => + TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); + TimelineService map(String userId, LatLngBounds bounds) => TimelineService(_timelineRepository.map(userId, bounds, groupBy)); } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 4d30e09716..8cbce084cd 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; @@ -126,4 +127,49 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { } return result; } + + Future> getRemovalCandidates( + String userId, + DateTime cutoffDate, { + AssetFilterType filterType = AssetFilterType.all, + bool keepFavorites = true, + }) async { + final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + ]) + ..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true)); + + final query = _db.localAssetEntity.select().join([ + innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)), + ]); + + Expression whereClause = + _db.localAssetEntity.createdAt.isSmallerOrEqualValue(cutoffDate) & + _db.remoteAssetEntity.ownerId.equals(userId) & + _db.remoteAssetEntity.deletedAt.isNull(); + + // Exclude assets that are in iOS shared albums + whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets); + + if (filterType == AssetFilterType.photosOnly) { + whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image); + } else if (filterType == AssetFilterType.videosOnly) { + whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video); + } + + if (keepFavorites) { + whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false); + } + + query.where(whereClause); + + final rows = await query.get(); + return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList(); + } } diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index d21e1e905b..66ae47a0b5 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -253,6 +253,24 @@ class DriftTimelineRepository extends DriftDatabaseRepository { origin: origin, ); + TimelineQuery fromAssetsWithBuckets(List assets, TimelineOrigin origin) { + // Sort assets by date descending and group by day + final sorted = List.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final Map bucketCounts = {}; + for (final asset in sorted) { + final date = DateTime(asset.createdAt.year, asset.createdAt.month, asset.createdAt.day); + bucketCounts[date] = (bucketCounts[date] ?? 0) + 1; + } + + final buckets = bucketCounts.entries.map((e) => TimeBucket(date: e.key, assetCount: e.value)).toList(); + + return ( + bucketSource: () => Stream.value(buckets), + assetSource: (offset, count) => Future.value(sorted.skip(offset).take(count).toList(growable: false)), + origin: origin, + ); + } + TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder( filter: (row) => row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId), diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 86c80253dc..a1d7e55f32 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewe import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart'; +import 'package:immich_mobile/widgets/settings/free_up_space_settings.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; import 'package:immich_mobile/widgets/settings/notification_setting.dart'; @@ -22,6 +23,7 @@ enum SettingSection { advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"), assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"), backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"), + freeUpSpace('free_up_space', Icons.cleaning_services_outlined, "free_up_space_settings_subtitle"), languages('language', Icons.language, "setting_languages_subtitle"), networking('networking_settings', Icons.wifi, "networking_subtitle"), notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"), @@ -38,6 +40,7 @@ enum SettingSection { SettingSection.assetViewer => const AssetViewerSettings(), SettingSection.backup => Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(), + SettingSection.freeUpSpace => const FreeUpSpaceSettings(), SettingSection.languages => const LanguageSettings(), SettingSection.networking => const NetworkingSettings(), SettingSection.notifications => const NotificationSetting(), diff --git a/mobile/lib/presentation/pages/cleanup_preview.page.dart b/mobile/lib/presentation/pages/cleanup_preview.page.dart new file mode 100644 index 0000000000..556ed6412f --- /dev/null +++ b/mobile/lib/presentation/pages/cleanup_preview.page.dart @@ -0,0 +1,42 @@ +import 'package:auto_route/auto_route.dart'; +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/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +@RoutePage() +class CleanupPreviewPage extends StatelessWidget { + final List assets; + + const CleanupPreviewPage({super.key, required this.assets}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('cleanup_preview_title'.t(context: context, args: {'count': assets.length.toString()})), + centerTitle: true, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: context.colorScheme.surface, + ), + body: ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith((ref) { + final timelineService = ref + .watch(timelineFactoryProvider) + .fromAssetsWithBuckets(assets.cast(), TimelineOrigin.search); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + ], + child: const Timeline(appBar: null, bottomSheet: null, groupBy: GroupAssetsBy.day, readOnly: true), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index a04e26d653..ac20e73190 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -42,6 +42,7 @@ class Timeline extends StatelessWidget { this.withScrubber = true, this.snapToMonth = true, this.initialScrollOffset, + this.readOnly = false, }); final Widget? topSliverWidget; @@ -54,6 +55,7 @@ class Timeline extends StatelessWidget { final bool withScrubber; final bool snapToMonth; final double? initialScrollOffset; + final bool readOnly; @override Widget build(BuildContext context) { @@ -73,6 +75,7 @@ class Timeline extends StatelessWidget { groupBy: groupBy, ), ), + if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), ], child: _SliverTimeline( topSliverWidget: topSliverWidget, @@ -89,6 +92,17 @@ class Timeline extends StatelessWidget { } } +class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier { + @override + bool build() => true; + + @override + void setReadonlyMode(bool value) {} + + @override + void toggleReadonlyMode() {} +} + class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ this.topSliverWidget, diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart new file mode 100644 index 0000000000..5b3b152f34 --- /dev/null +++ b/mobile/lib/providers/cleanup.provider.dart @@ -0,0 +1,106 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/cleanup.service.dart'; + +class CleanupState { + final DateTime? selectedDate; + final List assetsToDelete; + final bool isScanning; + final bool isDeleting; + final AssetFilterType filterType; + final bool keepFavorites; + + const CleanupState({ + this.selectedDate, + this.assetsToDelete = const [], + this.isScanning = false, + this.isDeleting = false, + this.filterType = AssetFilterType.all, + this.keepFavorites = true, + }); + + CleanupState copyWith({ + DateTime? selectedDate, + List? assetsToDelete, + bool? isScanning, + bool? isDeleting, + AssetFilterType? filterType, + bool? keepFavorites, + }) { + return CleanupState( + selectedDate: selectedDate ?? this.selectedDate, + assetsToDelete: assetsToDelete ?? this.assetsToDelete, + isScanning: isScanning ?? this.isScanning, + isDeleting: isDeleting ?? this.isDeleting, + filterType: filterType ?? this.filterType, + keepFavorites: keepFavorites ?? this.keepFavorites, + ); + } +} + +final cleanupProvider = StateNotifierProvider((ref) { + return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id); +}); + +class CleanupNotifier extends StateNotifier { + final CleanupService _cleanupService; + final String? _userId; + + CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState()); + + void setSelectedDate(DateTime? date) { + state = state.copyWith(selectedDate: date, assetsToDelete: []); + } + + void setFilterType(AssetFilterType filterType) { + state = state.copyWith(filterType: filterType, assetsToDelete: []); + } + + void setKeepFavorites(bool keepFavorites) { + state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []); + } + + Future scanAssets() async { + if (_userId == null || state.selectedDate == null) { + return; + } + + state = state.copyWith(isScanning: true); + try { + final assets = await _cleanupService.getRemovalCandidates( + _userId, + state.selectedDate!, + filterType: state.filterType, + keepFavorites: state.keepFavorites, + ); + state = state.copyWith(assetsToDelete: assets, isScanning: false); + } catch (e) { + state = state.copyWith(isScanning: false); + rethrow; + } + } + + Future deleteAssets() async { + if (state.assetsToDelete.isEmpty) { + return 0; + } + + state = state.copyWith(isDeleting: true); + try { + final deletedCount = await _cleanupService.deleteLocalAssets(state.assetsToDelete.map((a) => a.id).toList()); + + state = state.copyWith(assetsToDelete: [], isDeleting: false); + + return deletedCount; + } catch (e) { + state = state.copyWith(isDeleting: false); + rethrow; + } + } + + void reset() { + state = const CleanupState(); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 9c4a193381..9468b105e5 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -88,6 +88,7 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart'; +import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; @@ -338,6 +339,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 939bf73369..b287d73114 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -611,6 +611,43 @@ class ChangePasswordRoute extends PageRouteInfo { ); } +/// generated route for +/// [CleanupPreviewPage] +class CleanupPreviewRoute extends PageRouteInfo { + CleanupPreviewRoute({ + Key? key, + required List assets, + List? children, + }) : super( + CleanupPreviewRoute.name, + args: CleanupPreviewRouteArgs(key: key, assets: assets), + initialChildren: children, + ); + + static const String name = 'CleanupPreviewRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return CleanupPreviewPage(key: args.key, assets: args.assets); + }, + ); +} + +class CleanupPreviewRouteArgs { + const CleanupPreviewRouteArgs({this.key, required this.assets}); + + final Key? key; + + final List assets; + + @override + String toString() { + return 'CleanupPreviewRouteArgs{key: $key, assets: $assets}'; + } +} + /// generated route for /// [CreateAlbumPage] class CreateAlbumRoute extends PageRouteInfo { diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart new file mode 100644 index 0000000000..6a4318d209 --- /dev/null +++ b/mobile/lib/services/cleanup.service.dart @@ -0,0 +1,45 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; + +final cleanupServiceProvider = Provider((ref) { + return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider)); +}); + +class CleanupService { + final DriftLocalAssetRepository _localAssetRepository; + final AssetMediaRepository _assetMediaRepository; + + const CleanupService(this._localAssetRepository, this._assetMediaRepository); + + Future> getRemovalCandidates( + String userId, + DateTime cutoffDate, { + AssetFilterType filterType = AssetFilterType.all, + bool keepFavorites = true, + }) { + return _localAssetRepository.getRemovalCandidates( + userId, + cutoffDate, + filterType: filterType, + keepFavorites: keepFavorites, + ); + } + + Future deleteLocalAssets(List localIds) async { + if (localIds.isEmpty) { + return 0; + } + + final deletedIds = await _assetMediaRepository.deleteAll(localIds); + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + return deletedIds.length; + } + + return 0; + } +} diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart new file mode 100644 index 0000000000..7acb04686b --- /dev/null +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -0,0 +1,702 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/cleanup.provider.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class FreeUpSpaceSettings extends ConsumerStatefulWidget { + const FreeUpSpaceSettings({super.key}); + + @override + ConsumerState createState() => _FreeUpSpaceSettingsState(); +} + +class _FreeUpSpaceSettingsState extends ConsumerState { + CleanupStep _currentStep = CleanupStep.selectDate; + bool _hasScanned = false; + + void _resetState() { + ref.read(cleanupProvider.notifier).reset(); + _hasScanned = false; + } + + CleanupStep get _calculatedStep { + final state = ref.read(cleanupProvider); + + if (state.assetsToDelete.isNotEmpty) { + return CleanupStep.delete; + } + + if (state.selectedDate != null) { + return CleanupStep.filterOptions; + } + + return CleanupStep.selectDate; + } + + void _goToFiltersStep() { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + setState(() => _currentStep = CleanupStep.filterOptions); + } + + void _goToScanStep() { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + setState(() => _currentStep = CleanupStep.scan); + } + + void _setPresetDate(int daysAgo) { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + final date = DateTime.now().subtract(Duration(days: daysAgo)); + ref.read(cleanupProvider.notifier).setSelectedDate(date); + setState(() => _hasScanned = false); + } + + bool _isPresetSelected(int? daysAgo) { + final state = ref.read(cleanupProvider); + if (state.selectedDate == null) return false; + + final expectedDate = daysAgo != null ? DateTime.now().subtract(Duration(days: daysAgo)) : DateTime(2000); + + // Check if dates match (ignoring time component) + return state.selectedDate!.year == expectedDate.year && + state.selectedDate!.month == expectedDate.month && + state.selectedDate!.day == expectedDate.day; + } + + Future _selectDate() async { + final state = ref.read(cleanupProvider); + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + + final DateTime? picked = await showDatePicker( + context: context, + initialDate: state.selectedDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now(), + ); + + if (picked != null) { + ref.read(cleanupProvider.notifier).setSelectedDate(picked); + } + } + + Future _scanAssets() async { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + + await ref.read(cleanupProvider.notifier).scanAssets(); + final state = ref.read(cleanupProvider); + + setState(() { + _hasScanned = true; + if (state.assetsToDelete.isNotEmpty) { + _currentStep = CleanupStep.delete; + } + }); + } + + Future _deleteAssets() async { + final state = ref.read(cleanupProvider); + + if (state.assetsToDelete.isEmpty || state.selectedDate == null) { + return; + } + + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => + _DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!), + ); + + if (confirmed != true) { + return; + } + + final deletedCount = await ref.read(cleanupProvider.notifier).deleteAssets(); + + if (mounted && deletedCount > 0) { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + + await showDialog( + context: context, + builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount), + ); + } + + setState(() => _currentStep = CleanupStep.selectDate); + } + + void _showAssetsPreview(List assets) { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + context.pushRoute(CleanupPreviewRoute(assets: assets)); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(cleanupProvider); + final hasDate = state.selectedDate != null; + final hasAssets = _hasScanned && state.assetsToDelete.isNotEmpty; + + StepStyle styleForState(StepState stepState, {bool isDestructive = false}) { + switch (stepState) { + case StepState.complete: + return StepStyle( + color: context.colorScheme.primary, + indexStyle: TextStyle(color: context.colorScheme.onPrimary, fontWeight: FontWeight.w500), + ); + case StepState.disabled: + return StepStyle( + color: context.colorScheme.onSurface.withValues(alpha: 0.38), + indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500), + ); + case StepState.indexed: + case StepState.editing: + case StepState.error: + if (isDestructive) { + return StepStyle( + color: context.colorScheme.error, + indexStyle: TextStyle(color: context.colorScheme.onError, fontWeight: FontWeight.w500), + ); + } + return StepStyle( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500), + ); + } + } + + final step1State = hasDate ? StepState.complete : StepState.indexed; + final step2State = hasDate ? StepState.complete : StepState.disabled; + final step3State = hasAssets + ? StepState.complete + : hasDate + ? StepState.indexed + : StepState.disabled; + final step4State = hasAssets ? StepState.indexed : StepState.disabled; + + String getFilterSubtitle() { + final parts = []; + switch (state.filterType) { + case AssetFilterType.all: + parts.add('all'.t(context: context)); + case AssetFilterType.photosOnly: + parts.add('photos_only'.t(context: context)); + case AssetFilterType.videosOnly: + parts.add('videos_only'.t(context: context)); + } + if (state.keepFavorites) { + parts.add('keep_favorites'.t(context: context)); + } + return parts.join(' • '); + } + + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + _resetState(); + } + }, + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: context.primaryColor.withValues(alpha: 0.25)), + ), + child: Text( + 'free_up_space_description'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ), + ), + ), + + Stepper( + physics: const NeverScrollableScrollPhysics(), + currentStep: _currentStep.index, + onStepTapped: (step) { + // Only allow going back or to completed steps + if (step <= _calculatedStep.index) { + setState(() => _currentStep = CleanupStep.values[step]); + } + }, + controlsBuilder: (_, __) => const SizedBox.shrink(), + steps: [ + // Step 1: Select Cutoff Date + Step( + stepStyle: styleForState(step1State), + title: Text( + 'select_cutoff_date'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step1State == StepState.complete + ? context.colorScheme.primary + : context.colorScheme.onSurface, + ), + ), + subtitle: hasDate + ? Text( + DateFormat.yMMMd().format(state.selectedDate!), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('cutoff_date_description'.t(context: context), style: context.textTheme.labelLarge), + const SizedBox(height: 16), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.4, + children: [ + _DatePresetCard( + value: '30', + unit: 'cutoff_day'.t(context: context, args: {'count': '30'}), + onTap: () => _setPresetDate(30), + isSelected: _isPresetSelected(30), + ), + _DatePresetCard( + value: '60', + unit: 'cutoff_day'.t(context: context, args: {'count': '60'}), + + onTap: () => _setPresetDate(60), + isSelected: _isPresetSelected(60), + ), + _DatePresetCard( + value: '90', + unit: 'cutoff_day'.t(context: context, args: {'count': '90'}), + + onTap: () => _setPresetDate(90), + isSelected: _isPresetSelected(90), + ), + _DatePresetCard( + value: '1', + unit: 'cutoff_year'.t(context: context, args: {'count': '1'}), + onTap: () => _setPresetDate(365), + isSelected: _isPresetSelected(365), + ), + _DatePresetCard( + value: '2', + unit: 'cutoff_year'.t(context: context, args: {'count': '2'}), + onTap: () => _setPresetDate(730), + isSelected: _isPresetSelected(730), + ), + _DatePresetCard( + value: '3', + unit: 'cutoff_year'.t(context: context, args: {'count': '3'}), + onTap: () => _setPresetDate(1095), + isSelected: _isPresetSelected(1095), + ), + ], + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _selectDate, + icon: const Icon(Icons.calendar_today), + label: Text('custom_date'.t(context: context)), + style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: hasDate ? () => _goToFiltersStep() : null, + icon: const Icon(Icons.arrow_forward), + label: Text('continue'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + ], + ), + isActive: true, + state: step1State, + ), + + // Step 2: Select Filter Options + Step( + stepStyle: styleForState(step2State), + title: Text( + 'filter_options'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step2State == StepState.complete + ? context.colorScheme.primary + : step2State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.onSurface, + ), + ), + subtitle: hasDate + ? Text( + getFilterSubtitle(), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('cleanup_filter_description'.t(context: context), style: context.textTheme.labelLarge), + const SizedBox(height: 16), + SegmentedButton( + segments: [ + ButtonSegment( + value: AssetFilterType.all, + label: Text('all'.t(context: context)), + icon: const Icon(Icons.photo_library), + ), + ButtonSegment( + value: AssetFilterType.photosOnly, + label: Text('photos'.t(context: context)), + icon: const Icon(Icons.photo), + ), + ButtonSegment( + value: AssetFilterType.videosOnly, + label: Text('videos'.t(context: context)), + icon: const Icon(Icons.videocam), + ), + ], + selected: {state.filterType}, + onSelectionChanged: (selection) { + ref.read(cleanupProvider.notifier).setFilterType(selection.first); + setState(() => _hasScanned = false); + }, + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text('keep_favorites'.t(context: context), style: context.textTheme.titleSmall), + subtitle: Text( + 'keep_favorites_description'.t(context: context), + style: context.textTheme.labelLarge, + ), + value: state.keepFavorites, + onChanged: (value) { + ref.read(cleanupProvider.notifier).setKeepFavorites(value); + setState(() => _hasScanned = false); + }, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _goToScanStep, + icon: const Icon(Icons.arrow_forward), + label: Text('continue'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + ], + ), + isActive: hasDate, + state: step2State, + ), + + // Step 3: Scan Assets + Step( + stepStyle: styleForState(step3State), + title: Text( + 'scan'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step3State == StepState.complete + ? context.colorScheme.primary + : step3State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.onSurface, + ), + ), + subtitle: _hasScanned + ? Text( + 'cleanup_found_assets'.t( + context: context, + args: {'count': state.assetsToDelete.length.toString()}, + ), + style: context.textTheme.bodyMedium?.copyWith( + color: state.assetsToDelete.isNotEmpty + ? context.colorScheme.primary + : context.colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + children: [ + Text( + 'cleanup_step3_description'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ), + if (CurrentPlatform.isIOS) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: context.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'cleanup_icloud_shared_albums_excluded'.t(context: context), + style: context.textTheme.labelLarge, + ), + ), + ], + ), + ), + ], + const SizedBox(height: 16), + state.isScanning + ? SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2, + backgroundColor: context.colorScheme.primary.withAlpha(50), + ), + ) + : ElevatedButton.icon( + onPressed: state.isScanning ? null : _scanAssets, + icon: const Icon(Icons.search), + label: Text(_hasScanned ? 'rescan'.t(context: context) : 'scan'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + if (_hasScanned && state.assetsToDelete.isEmpty) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Row( + children: [ + const Icon(Icons.info, color: Colors.orange), + const SizedBox(width: 12), + Expanded( + child: Text( + 'cleanup_no_assets_found'.t(context: context), + style: context.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ], + ), + isActive: hasDate, + state: step3State, + ), + + // Step 4: Delete Assets + Step( + stepStyle: styleForState(step4State, isDestructive: true), + title: Text( + 'move_to_device_trash'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step4State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.error, + ), + ), + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)), + ), + child: hasAssets + ? Text( + 'cleanup_step4_summary'.t( + context: context, + args: { + 'count': state.assetsToDelete.length.toString(), + 'date': DateFormat.yMMMd().format(state.selectedDate!), + }, + ), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ) + : null, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => _showAssetsPreview(state.assetsToDelete), + icon: const Icon(Icons.preview), + label: Text('preview'.t(context: context)), + style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: state.isDeleting ? null : _deleteAssets, + icon: state.isDeleting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.delete_forever), + label: Text( + state.isDeleting + ? 'cleanup_deleting'.t(context: context) + : 'move_to_device_trash'.t(context: context), + ), + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error, + foregroundColor: context.colorScheme.onError, + minimumSize: const Size(double.infinity, 56), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ], + ), + isActive: hasAssets, + state: step4State, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _DeleteConfirmationDialog extends StatelessWidget { + final int assetCount; + final DateTime cutoffDate; + + const _DeleteConfirmationDialog({required this.assetCount, required this.cutoffDate}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('cleanup_confirm_prompt_title'.t(context: context)), + content: Text( + 'cleanup_confirm_description'.t( + context: context, + args: {'count': assetCount.toString(), 'date': DateFormat.yMMMd().format(cutoffDate)}, + ), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ), + actions: [ + TextButton( + onPressed: () => context.pop(false), + child: Text('cancel'.t(context: context)), + ), + ElevatedButton( + onPressed: () => context.pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error, + foregroundColor: context.colorScheme.onError, + ), + child: Text('confirm'.t(context: context)), + ), + ], + ); + } +} + +class _DeleteSuccessDialog extends StatelessWidget { + final int deletedCount; + + const _DeleteSuccessDialog({required this.deletedCount}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: Icon(Icons.check_circle, color: context.colorScheme.primary, size: 48), + title: Text('success'.t(context: context)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'cleanup_deleted_assets'.t(context: context, args: {'count': deletedCount.toString()}), + style: context.textTheme.labelLarge?.copyWith(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'cleanup_trash_hint'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(fontSize: 16, color: context.primaryColor), + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: () => context.pop(), + child: Text('done'.t(context: context)), + ), + ], + ); + } +} + +class _DatePresetCard extends StatelessWidget { + final String value; + final String unit; + final VoidCallback onTap; + final bool isSelected; + + const _DatePresetCard({required this.value, required this.unit, required this.onTap, required this.isSelected}); + + @override + Widget build(BuildContext context) { + return Material( + color: isSelected ? context.colorScheme.primaryContainer.withAlpha(100) : context.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: InkWell( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: isSelected ? context.colorScheme.primary : Colors.transparent, width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + value, + style: context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurface, + ), + ), + Text( + unit, + style: context.textTheme.bodySmall?.copyWith( + color: isSelected + ? context.colorScheme.primary + : context.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart new file mode 100644 index 0000000000..0d686fbc09 --- /dev/null +++ b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart @@ -0,0 +1,438 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; + +void main() { + late Drift db; + late DriftLocalAssetRepository repository; + + setUp(() { + db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + repository = DriftLocalAssetRepository(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('getRemovalCandidates', () { + final userId = 'user-123'; + final otherUserId = 'user-456'; + final now = DateTime(2024, 1, 15); + final cutoffDate = DateTime(2024, 1, 10); + final beforeCutoff = DateTime(2024, 1, 5); + final afterCutoff = DateTime(2024, 1, 12); + + Future insertUser(String id, String email) async { + await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email)); + } + + setUp(() async { + await insertUser(userId, 'user@test.com'); + await insertUser(otherUserId, 'other@test.com'); + }); + + Future insertLocalAsset({ + required String id, + required String checksum, + required DateTime createdAt, + required AssetType type, + required bool isFavorite, + }) async { + await db + .into(db.localAssetEntity) + .insert( + LocalAssetEntityCompanion.insert( + id: id, + name: 'asset_$id.jpg', + checksum: Value(checksum), + type: type, + createdAt: Value(createdAt), + updatedAt: Value(createdAt), + isFavorite: Value(isFavorite), + ), + ); + } + + Future insertRemoteAsset({ + required String id, + required String checksum, + required String ownerId, + DateTime? deletedAt, + }) async { + await db + .into(db.remoteAssetEntity) + .insert( + RemoteAssetEntityCompanion.insert( + id: id, + name: 'remote_$id.jpg', + checksum: checksum, + type: AssetType.image, + createdAt: Value(now), + updatedAt: Value(now), + ownerId: ownerId, + visibility: AssetVisibility.timeline, + deletedAt: Value(deletedAt), + ), + ); + } + + Future insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async { + await db + .into(db.localAlbumEntity) + .insert( + LocalAlbumEntityCompanion.insert( + id: id, + name: name, + updatedAt: Value(now), + backupSelection: BackupSelection.none, + isIosSharedAlbum: Value(isIosSharedAlbum), + ), + ); + } + + Future insertLocalAlbumAsset({required String albumId, required String assetId}) async { + await db + .into(db.localAlbumAssetEntity) + .insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId)); + } + + test('returns only assets that match all criteria', () async { + // Asset 1: Should be included - backed up, before cutoff, correct owner, not deleted, not favorite + await insertLocalAsset( + id: 'local-1', + checksum: 'checksum-1', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId); + + // Asset 2: Should NOT be included - not backed up (no remote asset) + await insertLocalAsset( + id: 'local-2', + checksum: 'checksum-2', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + + // Asset 3: Should NOT be included - after cutoff date + await insertLocalAsset( + id: 'local-3', + checksum: 'checksum-3', + createdAt: afterCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId); + + // Asset 4: Should NOT be included - different owner + await insertLocalAsset( + id: 'local-4', + checksum: 'checksum-4', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-4', checksum: 'checksum-4', ownerId: otherUserId); + + // Asset 5: Should NOT be included - remote asset is deleted + await insertLocalAsset( + id: 'local-5', + checksum: 'checksum-5', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-5', checksum: 'checksum-5', ownerId: userId, deletedAt: now); + + // Asset 6: Should NOT be included - is favorite (when keepFavorites=true) + await insertLocalAsset( + id: 'local-6', + checksum: 'checksum-6', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: true, + ); + await insertRemoteAsset(id: 'remote-6', checksum: 'checksum-6', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-1'); + }); + + test('includes favorites when keepFavorites is false', () async { + await insertLocalAsset( + id: 'local-favorite', + checksum: 'checksum-fav', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: true, + ); + await insertRemoteAsset(id: 'remote-favorite', checksum: 'checksum-fav', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-favorite'); + expect(candidates[0].isFavorite, true); + }); + + test('filters by photos only', () async { + // Photo + await insertLocalAsset( + id: 'local-photo', + checksum: 'checksum-photo', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId); + + // Video + await insertLocalAsset( + id: 'local-video', + checksum: 'checksum-video', + createdAt: beforeCutoff, + type: AssetType.video, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); + + final candidates = await repository.getRemovalCandidates( + userId, + cutoffDate, + filterType: AssetFilterType.photosOnly, + ); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-photo'); + expect(candidates[0].type, AssetType.image); + }); + + test('filters by videos only', () async { + // Photo + await insertLocalAsset( + id: 'local-photo', + checksum: 'checksum-photo', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId); + + // Video + await insertLocalAsset( + id: 'local-video', + checksum: 'checksum-video', + createdAt: beforeCutoff, + type: AssetType.video, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); + + final candidates = await repository.getRemovalCandidates( + userId, + cutoffDate, + filterType: AssetFilterType.videosOnly, + ); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-video'); + expect(candidates[0].type, AssetType.video); + }); + + test('returns both photos and videos with filterType.all', () async { + // Photo + await insertLocalAsset( + id: 'local-photo', + checksum: 'checksum-photo', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId); + + // Video + await insertLocalAsset( + id: 'local-video', + checksum: 'checksum-video', + createdAt: beforeCutoff, + type: AssetType.video, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate, filterType: AssetFilterType.all); + + expect(candidates.length, 2); + final ids = candidates.map((a) => a.id).toSet(); + expect(ids, containsAll(['local-photo', 'local-video'])); + }); + + test('excludes assets in iOS shared albums', () async { + // Regular album + await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false); + + // iOS shared album + await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true); + + // Asset in regular album (should be included) + await insertLocalAsset( + id: 'local-regular', + checksum: 'checksum-regular', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-regular', checksum: 'checksum-regular', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-regular'); + + // Asset in iOS shared album (should be excluded) + await insertLocalAsset( + id: 'local-shared', + checksum: 'checksum-shared', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-shared', checksum: 'checksum-shared', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-shared'); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-regular'); + }); + + test('includes assets at exact cutoff date', () async { + await insertLocalAsset( + id: 'local-exact', + checksum: 'checksum-exact', + createdAt: cutoffDate, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-exact', checksum: 'checksum-exact', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-exact'); + }); + + test('returns empty list when no assets match criteria', () async { + // Only assets after cutoff + await insertLocalAsset( + id: 'local-after', + checksum: 'checksum-after', + createdAt: afterCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-after', checksum: 'checksum-after', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates, isEmpty); + }); + + test('handles multiple assets with same checksum', () async { + // Two local assets with same checksum (edge case, but should handle it) + await insertLocalAsset( + id: 'local-dup1', + checksum: 'checksum-dup', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertLocalAsset( + id: 'local-dup2', + checksum: 'checksum-dup', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-dup', checksum: 'checksum-dup', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 2); + expect(candidates.map((a) => a.checksum).toSet(), equals({'checksum-dup'})); + }); + + test('includes assets not in any album', () async { + // Asset not in any album should be included + await insertLocalAsset( + id: 'local-no-album', + checksum: 'checksum-no-album', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-no-album'); + }); + + test('excludes asset that is in both regular and iOS shared album', () async { + // Regular album + await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false); + + // iOS shared album + await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true); + + // Asset in BOTH albums - should be excluded because it's in an iOS shared album + await insertLocalAsset( + id: 'local-both', + checksum: 'checksum-both', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-both'); + await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-both'); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates, isEmpty); + }); + + test('excludes assets with null checksum (not backed up)', () async { + // Asset with null checksum cannot be matched to remote asset + await db + .into(db.localAssetEntity) + .insert( + LocalAssetEntityCompanion.insert( + id: 'local-null-checksum', + name: 'asset_null.jpg', + checksum: const Value.absent(), // null checksum + type: AssetType.image, + createdAt: Value(beforeCutoff), + updatedAt: Value(beforeCutoff), + isFavorite: const Value(false), + ), + ); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates, isEmpty); + }); + }); +} From 1f20b6471cc6c3e142962e5ac16772157673d444 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 7 Jan 2026 21:02:21 -0600 Subject: [PATCH 17/17] feat: use fastlane sigh to manage signing profiles (#25089) * feat: use fastlane sigh to manage signing profiles * remove unused secrects * remove unused fallback --- .github/workflows/build-mobile.yml | 35 +--------- mobile/ios/.gitignore | 3 +- mobile/ios/fastlane/Fastfile | 100 +++++++++++++++++++++-------- 3 files changed, 75 insertions(+), 63 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 10dc88088f..72c816cc93 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -30,18 +30,6 @@ on: required: true IOS_CERTIFICATE_PASSWORD: required: true - IOS_PROVISIONING_PROFILE: - required: true - IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: - required: true - IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: - required: true - IOS_DEVELOPMENT_PROVISIONING_PROFILE: - required: true - IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: - required: true - IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: - required: true FASTLANE_TEAM_ID: required: true pull_request: @@ -240,35 +228,14 @@ jobs: mkdir -p ~/.appstoreconnect/private_keys echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8 - - name: Import Certificate and Provisioning Profiles + - name: Import Certificate env: IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} - IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }} - IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - ENVIRONMENT: ${{ inputs.environment || 'development' }} working-directory: ./mobile/ios run: | # Decode certificate echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12 - # Decode provisioning profiles based on environment - if [[ "$ENVIRONMENT" == "development" ]]; then - echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision - echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision - echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision - ls -lh profile_dev*.mobileprovision - else - echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision - echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision - echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision - ls -lh profile*.mobileprovision - fi - - name: Create keychain and import certificate env: KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore index f1a46a2fef..63e84080df 100644 --- a/mobile/ios/.gitignore +++ b/mobile/ios/.gitignore @@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.* !default.perspectivev3 fastlane/report.xml -Gemfile.lock \ No newline at end of file +Gemfile.lock +certs/ \ No newline at end of file diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index d167d5fb2d..9c31ced00d 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -44,7 +44,7 @@ def get_version_from_pubspec end # Helper method to configure code signing for all targets - def configure_code_signing(bundle_id_suffix: "") + def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:) bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" # Runner (main app) @@ -54,7 +54,7 @@ end team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, code_sign_identity: CODE_SIGN_IDENTITY, bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}", - profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore", + profile_name: profile_name_main, targets: ["Runner"] ) @@ -65,7 +65,7 @@ end team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, code_sign_identity: CODE_SIGN_IDENTITY, bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension", - profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore", + profile_name: profile_name_share, targets: ["ShareExtension"] ) @@ -76,7 +76,7 @@ end team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, code_sign_identity: CODE_SIGN_IDENTITY, bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget", - profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore", + profile_name: profile_name_widget, targets: ["WidgetExtension"] ) end @@ -87,7 +87,10 @@ end bundle_id_suffix: "", configuration: "Release", distribute_external: true, - version_number: nil + version_number: nil, + profile_name_main:, + profile_name_share:, + profile_name_widget: ) bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}" @@ -115,9 +118,9 @@ end xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", export_options: { provisioningProfiles: { - "#{app_identifier}" => "#{app_identifier} AppStore", - "#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore", - "#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore" + "#{app_identifier}" => profile_name_main, + "#{app_identifier}.ShareExtension" => profile_name_share, + "#{app_identifier}.Widget" => profile_name_widget }, signingStyle: "manual", signingCertificate: CODE_SIGN_IDENTITY @@ -136,20 +139,35 @@ end lane :gha_testflight_dev do api_key = get_api_key - # Install development provisioning profiles - install_provisioning_profile(path: "profile_dev.mobileprovision") - install_provisioning_profile(path: "profile_dev_share.mobileprovision") - install_provisioning_profile(path: "profile_dev_widget.mobileprovision") + # Download and install provisioning profiles from App Store Connect + # Certificate is imported by GHA workflow into build.keychain + # Capture profile names after each sigh call + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true) + main_profile_name = lane_context[SharedValues::SIGH_NAME] - # Configure code signing for dev bundle IDs - configure_code_signing(bundle_id_suffix: "development") + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true) + share_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true) + widget_profile_name = lane_context[SharedValues::SIGH_NAME] + + # Configure code signing for dev bundle IDs using the downloaded profile names + configure_code_signing( + bundle_id_suffix: "development", + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name + ) # Build and upload build_and_upload( api_key: api_key, bundle_id_suffix: "development", configuration: "Profile", - distribute_external: false + distribute_external: false, + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name ) end @@ -157,20 +175,33 @@ end lane :gha_release_prod do api_key = get_api_key - # Install provisioning profiles - install_provisioning_profile(path: "profile.mobileprovision") - install_provisioning_profile(path: "profile_share.mobileprovision") - install_provisioning_profile(path: "profile_widget.mobileprovision") + # Download and install provisioning profiles from App Store Connect + # Certificate is imported by GHA workflow into build.keychain + sigh(api_key: api_key, app_identifier: BASE_BUNDLE_ID, force: true) + main_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.ShareExtension", force: true) + share_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.Widget", force: true) + widget_profile_name = lane_context[SharedValues::SIGH_NAME] # Configure code signing for production bundle IDs - configure_code_signing + configure_code_signing( + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name + ) # Build and upload with version number build_and_upload( api_key: api_key, version_number: get_version_from_pubspec, distribute_external: false, + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name ) end @@ -215,13 +246,26 @@ end # Use the same build process as production, just skip the upload # This ensures PR builds validate the same way as production builds - # Install provisioning profiles (use development profiles for PR builds) - install_provisioning_profile(path: "profile_dev.mobileprovision") - install_provisioning_profile(path: "profile_dev_share.mobileprovision") - install_provisioning_profile(path: "profile_dev_widget.mobileprovision") + api_key = get_api_key + + # Download and install provisioning profiles from App Store Connect + # Certificate is imported by GHA workflow into build.keychain + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true) + main_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true) + share_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true) + widget_profile_name = lane_context[SharedValues::SIGH_NAME] # Configure code signing for dev bundle IDs - configure_code_signing(bundle_id_suffix: "development") + configure_code_signing( + bundle_id_suffix: "development", + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name + ) # Build the app (same as gha_testflight_dev but without upload) build_app( @@ -233,9 +277,9 @@ end xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", export_options: { provisioningProfiles: { - "#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore", - "#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore", - "#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore" + "#{BASE_BUNDLE_ID}.development" => main_profile_name, + "#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name, + "#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name }, signingStyle: "manual", signingCertificate: CODE_SIGN_IDENTITY