From 1a24a2d35e471e3ece11411a86d5e81343bfd893 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 6 Jan 2026 17:35:37 -0500 Subject: [PATCH 01/30] 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 02/30] 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 03/30] 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 04/30] 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 05/30] 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 06/30] 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 07/30] 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 08/30] 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 09/30] 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 10/30] 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 11/30] 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 12/30] 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 From fbd49e0b7979f939114d5a10f94b20d7735a914c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 8 Jan 2026 12:40:17 -0500 Subject: [PATCH 13/30] refactor: memory lane (#25134) --- pnpm-lock.yaml | 10 +- web/package.json | 2 +- .../memory-page/memory-viewer.svelte | 2 +- .../components/photos-page/memory-lane.svelte | 107 ------------------ web/src/lib/managers/event-manager.svelte.ts | 4 +- web/src/lib/stores/memory.store.svelte.ts | 31 ++--- web/src/lib/utils/auth.ts | 3 + .../(user)/photos/[[assetId=id]]/+page.svelte | 22 +++- web/src/routes/+layout.svelte | 2 + 9 files changed, 50 insertions(+), 133 deletions(-) delete mode 100644 web/src/lib/components/photos-page/memory-lane.svelte diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4ffc00639..a851b1a809 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.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) + specifier: ^0.54.0 + version: 0.54.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) '@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.53.3': - resolution: {integrity: sha512-Ax7ctU9KIZgET58+PoMQnf1XDOIH76Xa341TXDfLwF96F3fQZ/v4TA7Ycb6hmTwIYGU9arIgqGqQDbuuNxc2vA==} + '@immich/ui@0.54.0': + resolution: {integrity: sha512-6jvkvKhgsZ7LvspaJkbht/f8W5IRm+vjYkcZecShFAPaxaowbm7io9sO15MpJdIQfPdXg7vwLI527PV3vlBc6A==} peerDependencies: svelte: ^5.0.0 @@ -15078,7 +15078,7 @@ snapshots: dependencies: 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)': + '@immich/ui@0.54.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)': 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 ef48a8a92f..acd888783f 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.53.3", + "@immich/ui": "^0.54.0", "@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/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 34c6ee18db..ff1597fedb 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -270,7 +270,7 @@ }; afterNavigate(({ from, to }) => { - memoryStore.initialize().then( + memoryStore.ready().then( () => { let target = null; if (to?.params?.assetId) { diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte deleted file mode 100644 index 1d61b61b40..0000000000 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ /dev/null @@ -1,107 +0,0 @@ - - -{#if shouldRender} -
(offsetWidth = width)} - onscroll={onScroll} - > - {#if canScrollLeft || canScrollRight} -
- {#if canScrollLeft} -
- -
- {/if} - {#if canScrollRight} -
- -
- {/if} -
- {/if} -
(innerWidth = width)}> - {#each memoryStore.memories as memory (memory.id)} - - {$t('memory_lane_title', -
-

- {$memoryLaneTitle(memory)} -

-
- {/each} -
-
-{/if} - - diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index f9fa87e0cf..5b9803edff 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -15,9 +15,11 @@ import type { export type Events = { AppInit: []; - UserLogin: []; + AuthLogin: [LoginResponseDto]; AuthLogout: []; + AuthUserLoaded: [UserAdminResponseDto]; + LanguageChange: [{ name: string; code: string; rtl?: boolean }]; ThemeChange: [ThemeSetting]; diff --git a/web/src/lib/stores/memory.store.svelte.ts b/web/src/lib/stores/memory.store.svelte.ts index 05f45b3d7d..357de394ae 100644 --- a/web/src/lib/stores/memory.store.svelte.ts +++ b/web/src/lib/stores/memory.store.svelte.ts @@ -20,12 +20,18 @@ export type MemoryAsset = MemoryIndex & { }; class MemoryStoreSvelte { + #loading: Promise | undefined; + constructor() { eventManager.on('AuthLogout', () => this.clearCache()); + eventManager.on('AuthUserLoaded', () => void this.initialize()); + } + + ready() { + return this.initialize(); } memories = $state([]); - private initialized = false; private memoryAssets = $derived.by(() => { const memoryAssets: MemoryAsset[] = []; let previous: MemoryAsset | undefined; @@ -101,21 +107,20 @@ class MemoryStoreSvelte { } } - async initialize() { - if (this.initialized) { - return; - } - this.initialized = true; - - await this.loadAllMemories(); - } - - clearCache() { - this.initialized = false; + private clearCache() { + this.#loading = undefined; this.memories = []; } - private async loadAllMemories() { + private initialize() { + if (!this.#loading) { + this.#loading = this.load(); + } + + return this.#loading; + } + + private async load() { const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) }); this.memories = memories.filter((memory) => memory.assets.length > 0); } diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index feb875e5ec..99935b8938 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,4 +1,5 @@ import { browser } from '$app/environment'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; import { preferences as preferences$, user as user$ } from '$lib/stores/user.store'; import { userInteraction } from '$lib/stores/user.svelte'; @@ -24,6 +25,8 @@ export const loadUser = async () => { user$.set(user); preferences$.set(preferences); + eventManager.emit('AuthUserLoaded', user); + // Check for license status if (serverInfo.licensed || user.license?.activatedAt) { purchaseStore.setPurchaseStatus(true); diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 8bf8dce94e..c0d05bdfcb 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -1,7 +1,6 @@ @@ -98,7 +110,7 @@ withStacked > {#if $preferences.memories.enabled} - + {/if} {#snippet empty()} openFileUploadDialog()} class="mt-10 mx-auto" /> diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1c7a190b08..286573ef78 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -49,6 +49,8 @@ toast_info_title: $t('info'), toast_warning_title: $t('warning'), toast_danger_title: $t('error'), + navigate_next: $t('next'), + navigate_previous: $t('previous'), }); }); From 109c79125da71e3efa347ac1a2e82301d6800ad8 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 8 Jan 2026 13:32:43 -0600 Subject: [PATCH 14/30] fix: description does not rerender when navigating between assets (#25137) --- .../asset-viewer/detail-panel-description.svelte | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte index b41891de82..a1ffd8441b 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -13,17 +13,16 @@ let { asset, isOwner }: Props = $props(); - let currentDescription = asset.exifInfo?.description ?? ''; - let draftDescription = $state(currentDescription); + let currentDescription = $derived(asset.exifInfo?.description ?? ''); + let description = $derived(currentDescription); const handleFocusOut = async () => { - if (draftDescription === currentDescription) { + if (description === currentDescription) { return; } try { - await updateAsset({ id: asset.id, updateAssetDto: { description: draftDescription } }); + await updateAsset({ id: asset.id, updateAssetDto: { description } }); toastManager.success($t('asset_description_updated')); - currentDescription = draftDescription; } catch (error) { handleError(error, $t('cannot_update_the_description')); } @@ -33,7 +32,7 @@ {#if isOwner}