From 83a851b55650c93318b45b14903cd3407c999dab Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 16 Jun 2024 17:37:25 +0200 Subject: [PATCH 01/15] fix(web): play video muted when blocked by browser (#10383) --- .../asset-viewer/video-native-viewer.svelte | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 1fae59a0da..62dec38eab 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -4,7 +4,7 @@ import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { AssetMediaSize } from '@immich/sdk'; - import { createEventDispatcher } from 'svelte'; + import { createEventDispatcher, tick } from 'svelte'; import { fade } from 'svelte/transition'; import { t } from 'svelte-i18n'; @@ -15,28 +15,40 @@ let element: HTMLVideoElement | undefined = undefined; let isVideoLoading = true; let assetFileUrl: string; + let forceMuted = false; - $: { - const next = getAssetPlaybackUrl({ id: assetId, checksum }); - if (assetFileUrl !== next) { - assetFileUrl = next; - element && element.load(); - } + $: if (element) { + assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum }); + forceMuted = false; + element.load(); } const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>(); - const handleCanPlay = async (event: Event) => { + const handleCanPlay = async (video: HTMLVideoElement) => { try { - const video = event.currentTarget as HTMLVideoElement; await video.play(); dispatch('onVideoStarted'); } catch (error) { + if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) { + await tryForceMutedPlay(video); + return; + } handleError(error, $t('errors.unable_to_play_video')); } finally { isVideoLoading = false; } }; + + const tryForceMutedPlay = async (video: HTMLVideoElement) => { + try { + forceMuted = true; + await tick(); + await handleCanPlay(video); + } catch (error) { + handleError(error, $t('errors.unable_to_play_video')); + } + };
handleCanPlay(e.currentTarget)} on:ended={() => dispatch('onVideoEnded')} - bind:muted={$videoViewerMuted} + on:volumechange={(e) => { + if (!forceMuted) { + $videoViewerMuted = e.currentTarget.muted; + } + }} + muted={forceMuted || $videoViewerMuted} bind:volume={$videoViewerVolume} poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })} > From 010eb1e0d6da353fa727209959966a4e1a56fb85 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 16 Jun 2024 17:37:51 +0200 Subject: [PATCH 02/15] fix(server): include trashed assets in forced thumbnail generation (#10389) * fix(server): include trashed assets in forced thumbnail generation * deleted -> trashed --- server/src/services/media.service.spec.ts | 25 ++++++++++++++ server/src/services/media.service.ts | 2 +- server/test/fixtures/asset.stub.ts | 40 +++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index ef08f059a3..d5bda48fbb 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -107,6 +107,31 @@ describe(MediaService.name, () => { ]); }); + it('should queue trashed assets when force is true', async () => { + assetMock.getAll.mockResolvedValue({ + items: [assetStub.trashed], + hasNextPage: false, + }); + personMock.getAll.mockResolvedValue({ + items: [], + hasNextPage: false, + }); + + await sut.handleQueueGenerateThumbnails({ force: true }); + + expect(assetMock.getAll).toHaveBeenCalledWith( + { skip: 0, take: 1000 }, + expect.objectContaining({ withDeleted: true }), + ); + expect(assetMock.getWithout).not.toHaveBeenCalled(); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.GENERATE_PREVIEW, + data: { id: assetStub.trashed.id }, + }, + ]); + }); + it('should queue all people with missing thumbnail path', async () => { assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index fc1f16a638..6e5778c4df 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -70,7 +70,7 @@ export class MediaService { async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { isVisible: true }) + ? this.assetRepository.getAll(pagination, { isVisible: true, withDeleted: true }) : this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL); }); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 01d5e8c119..70075669e6 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -209,6 +209,46 @@ export const assetStub = { duplicateId: null, }), + trashed: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + previewPath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + thumbnailPath: '/uploads/user-id/webp/path.ext', + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + deletedAt: new Date('2023-02-24T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: false, + isArchived: false, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as ExifEntity, + duplicateId: null, + }), + external: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', From 0b08af7082956ca66f5e2160c700dc190b293358 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 16 Jun 2024 17:38:32 +0200 Subject: [PATCH 03/15] fix(web): update avatar color immediately (#10393) --- .../navigation-bar/account-info-panel.svelte | 6 ++--- .../shared-components/user-avatar.svelte | 22 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte index 743e808939..60bc0b6c61 100644 --- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte @@ -29,6 +29,7 @@ } $preferences = await updateMyPreferences({ userPreferencesUpdateDto: { avatar: { color } } }); + $user = { ...$user, profileImagePath: '', avatarColor: $preferences.avatar.color }; isShowSelectAvatar = false; notificationController.show({ @@ -52,9 +53,7 @@ class="mx-4 mt-4 flex flex-col items-center justify-center gap-4 rounded-3xl bg-white p-4 dark:bg-immich-dark-primary/10" >
- {#key $user} - - {/key} +
+ {#if isShowSelectAvatar} import { getProfileImageUrl } from '$lib/utils'; import { type UserAvatarColor } from '@immich/sdk'; - import { onMount, tick } from 'svelte'; interface User { id: string; @@ -16,7 +15,7 @@ } export let user: User; - export let color: UserAvatarColor = user.avatarColor; + export let color: UserAvatarColor | undefined = undefined; export let size: Size = 'full'; export let rounded = true; export let interactive = false; @@ -27,15 +26,16 @@ let img: HTMLImageElement; let showFallback = true; - onMount(async () => { - if (!user.profileImagePath) { - return; - } + $: img, user, void tryLoadImage(); - await img.decode(); - await tick(); - showFallback = false; - }); + const tryLoadImage = async () => { + try { + await img.decode(); + showFallback = false; + } catch { + showFallback = true; + } + }; const colorClasses: Record = { primary: 'bg-immich-primary dark:bg-immich-dark-primary text-immich-dark-fg dark:text-immich-fg', @@ -60,7 +60,7 @@ xxxl: 'w-28 h-28', }; - $: colorClass = colorClasses[color]; + $: colorClass = colorClasses[color || user.avatarColor]; $: sizeClass = sizeClasses[size]; $: title = label ?? `${user.name} (${user.email})`; $: interactiveClass = interactive From e77e87b936786a6221eb2df2ceda1b245ea2f5dc Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sun, 16 Jun 2024 11:45:58 -0400 Subject: [PATCH 04/15] fix(server): orientation handling for person thumbnails (#10382) fix orientation handling --- server/src/interfaces/media.interface.ts | 4 +++ server/src/services/person.service.spec.ts | 6 ++--- server/src/services/person.service.ts | 30 ++++++---------------- server/test/fixtures/face.stub.ts | 4 +-- 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 11ee525f8a..fdf70865ef 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -47,6 +47,10 @@ export interface ImageDimensions { height: number; } +export interface InputDimensions extends ImageDimensions { + inputPath: string; +} + export interface VideoInfo { format: VideoFormat; videoStreams: VideoStreamInfo[]; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index bb76bc38a3..bf6fc8207e 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -917,9 +917,9 @@ describe(PersonService.name, () => { colorspace: Colorspace.P3, crop: { left: 0, - top: 428, - width: 1102, - height: 1102, + top: 85, + width: 510, + height: 510, }, }, ); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e3e78b48f2..57940f3113 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -41,13 +41,12 @@ import { } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { BoundingBox, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { CropOptions, IMediaRepository, ImageDimensions } from 'src/interfaces/media.interface'; +import { CropOptions, IMediaRepository, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { Orientation } from 'src/services/metadata.service'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFacialRecognitionEnabled } from 'src/utils/misc'; @@ -520,7 +519,7 @@ export class PersonService { return JobStatus.FAILED; } - const { width, height, inputPath } = await this.getInputDimensions(asset); + const { width, height, inputPath } = await this.getInputDimensions(asset, { width: oldWidth, height: oldHeight }); const thumbnailPath = StorageCore.getPersonThumbnailPath(person); this.storageCore.ensureFolders(thumbnailPath); @@ -601,7 +600,7 @@ export class PersonService { return person; } - private async getInputDimensions(asset: AssetEntity): Promise { + private async getInputDimensions(asset: AssetEntity, oldDims: ImageDimensions): Promise { if (!asset.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) { throw new Error(`Asset ${asset.id} dimensions are unknown`); } @@ -611,10 +610,11 @@ export class PersonService { } if (asset.type === AssetType.IMAGE) { - const { width, height } = this.withOrientation(asset.exifInfo.orientation as Orientation, { - width: asset.exifInfo.exifImageWidth, - height: asset.exifInfo.exifImageHeight, - }); + let { exifImageWidth: width, exifImageHeight: height } = asset.exifInfo; + if (oldDims.height > oldDims.width !== height > width) { + [width, height] = [height, width]; + } + return { width, height, inputPath: asset.originalPath }; } @@ -622,20 +622,6 @@ export class PersonService { return { width, height, inputPath: asset.previewPath }; } - private withOrientation(orientation: Orientation, { width, height }: ImageDimensions): ImageDimensions { - switch (orientation) { - case Orientation.MirrorHorizontalRotate270CW: - case Orientation.Rotate90CW: - case Orientation.MirrorHorizontalRotate90CW: - case Orientation.Rotate270CW: { - return { width: height, height: width }; - } - default: { - return { width, height }; - } - } - } - private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions { const widthScale = dims.new.width / dims.old.width; const heightScale = dims.new.height / dims.old.height; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 2d2acec40d..5ecb5701ce 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -72,8 +72,8 @@ export const faceStub = { boundingBoxY1: 5, boundingBoxX2: 505, boundingBoxY2: 505, - imageHeight: 1000, - imageWidth: 1000, + imageHeight: 2880, + imageWidth: 2160, }), middle: Object.freeze>({ id: 'assetFaceId6', From 0fe152b1efc61130ccb81587442b55986c237704 Mon Sep 17 00:00:00 2001 From: RanKKI Date: Mon, 17 Jun 2024 01:54:15 +1000 Subject: [PATCH 05/15] fix(mobile): translation for title (#10324) * fix(memory): translation for title * chore: update memoery translation for dutch * refactor(translation): avoid incompatibility with i18n website * fix: lint errors --- mobile/assets/i18n/en-US.json | 2 ++ mobile/assets/i18n/ja-JP.json | 2 ++ mobile/assets/i18n/nl-NL.json | 2 ++ mobile/assets/i18n/zh-CN.json | 2 ++ mobile/assets/i18n/zh-Hans.json | 2 ++ mobile/assets/i18n/zh-TW.json | 2 ++ mobile/lib/services/memory.service.dart | 8 +++++--- 7 files changed, 17 insertions(+), 3 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index e760a111b4..5f86e4064c 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -295,6 +295,8 @@ "memories_check_back_tomorrow": "Check back tomorrow for more memories", "memories_start_over": "Start Over", "memories_swipe_to_close": "Swipe up to close", + "memories_year_ago": "A year ago", + "memories_years_ago": "{} years ago", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", diff --git a/mobile/assets/i18n/ja-JP.json b/mobile/assets/i18n/ja-JP.json index 075c6af859..8d935440af 100644 --- a/mobile/assets/i18n/ja-JP.json +++ b/mobile/assets/i18n/ja-JP.json @@ -295,6 +295,8 @@ "memories_check_back_tomorrow": "明日もう一度確認してください", "memories_start_over": "始める", "memories_swipe_to_close": "上にスワイプして閉じる", + "memories_year_ago": "過去1年間", + "memories_years_ago": "過去{}年間", "monthly_title_text_date_format": "yyyy年 MM月", "motion_photos_page_title": "モーションフォト", "multiselect_grid_edit_date_time_err_read_only": "読み取り専用の項目の日付を変更できません", diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index ba6d58eb56..6d2c36a986 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -295,6 +295,8 @@ "memories_check_back_tomorrow": "Kom morgen terug voor meer herinneringen", "memories_start_over": "Opnieuw beginnen", "memories_swipe_to_close": "Swipe omhoog om te sluiten", + "memories_year_ago": "1 jaar geleden", + "memories_years_ago": "{} jaar geleden", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Bewegende foto's", "multiselect_grid_edit_date_time_err_read_only": "Kan datum van alleen-lezen asset(s) niet wijzigen, overslaan", diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index 805b7e40c7..cdddf657db 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -295,6 +295,8 @@ "memories_check_back_tomorrow": "明天再看", "memories_start_over": "再看一次", "memories_swipe_to_close": "上划关闭", + "memories_year_ago": "1年前", + "memories_years_ago": "{}年前", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "动图", "multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过", diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json index 0dffda78dd..06bc3e2cd1 100644 --- a/mobile/assets/i18n/zh-Hans.json +++ b/mobile/assets/i18n/zh-Hans.json @@ -295,6 +295,8 @@ "memories_check_back_tomorrow": "明天再看", "memories_start_over": "再看一次", "memories_swipe_to_close": "上划关闭", + "memories_year_ago": "1年前", + "memories_years_ago": "{}年前", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "动图", "multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过", diff --git a/mobile/assets/i18n/zh-TW.json b/mobile/assets/i18n/zh-TW.json index e760a111b4..73ac8910f3 100644 --- a/mobile/assets/i18n/zh-TW.json +++ b/mobile/assets/i18n/zh-TW.json @@ -295,6 +295,8 @@ "memories_check_back_tomorrow": "Check back tomorrow for more memories", "memories_start_over": "Start Over", "memories_swipe_to_close": "Swipe up to close", + "memories_year_ago": "1年前", + "memories_years_ago": "{}年前", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index 613b6ed91e..ea07f7c019 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; @@ -8,8 +9,6 @@ import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -import '../utils/string_helper.dart'; - final memoryServiceProvider = StateProvider((ref) { return MemoryService( ref.watch(apiServiceProvider), @@ -42,9 +41,12 @@ class MemoryService { final dbAssets = await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); if (dbAssets.isNotEmpty) { + final String title = yearsAgo <= 1 + ? 'memories_year_ago'.tr() + : 'memories_years_ago'.tr(args: [yearsAgo.toString()]); memories.add( Memory( - title: '$yearsAgo year${s(yearsAgo)} ago', + title: title, assets: dbAssets, ), ); From 6b1b5054f852641b7b7626b459fbc393d760dee9 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sun, 16 Jun 2024 15:25:27 -0400 Subject: [PATCH 06/15] feat(server): separate face search relation (#10371) * wip * various fixes * new migration * fix test * add face search entity, update sql * update e2e * set storage to external --- e2e/src/utils.ts | 9 +--- server/src/entities/asset-face.entity.ts | 8 +-- server/src/entities/face-search.entity.ts | 21 ++++++++ server/src/entities/index.ts | 2 + .../1718486162779-AddFaceSearchRelation.ts | 54 +++++++++++++++++++ server/src/queries/search.repository.sql | 5 +- .../src/repositories/database.repository.ts | 2 +- server/src/repositories/person.repository.ts | 7 +-- server/src/repositories/search.repository.ts | 5 +- server/src/services/person.service.spec.ts | 5 +- server/src/services/person.service.ts | 41 ++++++++------ server/test/fixtures/face.stub.ts | 18 +++---- 12 files changed, 130 insertions(+), 47 deletions(-) create mode 100644 server/src/entities/face-search.entity.ts create mode 100644 server/src/migrations/1718486162779-AddFaceSearchRelation.ts diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 72b3480299..f3afdddf8b 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -398,14 +398,7 @@ export const utils = { return; } - const vector = Array.from({ length: 512 }, Math.random); - const embedding = `[${vector.join(',')}]`; - - await client.query('INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', [ - assetId, - personId, - embedding, - ]); + await client.query('INSERT INTO asset_faces ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]); }, setPersonThumbnail: async (personId: string) => { diff --git a/server/src/entities/asset-face.entity.ts b/server/src/entities/asset-face.entity.ts index 38fcd46063..c21aacfcd1 100644 --- a/server/src/entities/asset-face.entity.ts +++ b/server/src/entities/asset-face.entity.ts @@ -1,6 +1,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; +import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, Index, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; @Entity('asset_faces', { synchronize: false }) @Index('IDX_asset_faces_assetId_personId', ['assetId', 'personId']) @@ -15,9 +16,8 @@ export class AssetFaceEntity { @Column({ nullable: true, type: 'uuid' }) personId!: string | null; - @Index('face_index', { synchronize: false }) - @Column({ type: 'float4', array: true, select: false, transformer: { from: (v) => JSON.parse(v), to: (v) => v } }) - embedding!: number[]; + @OneToOne(() => FaceSearchEntity, (faceSearchEntity) => faceSearchEntity.face, { cascade: ['insert'] }) + faceSearch?: FaceSearchEntity; @Column({ default: 0, type: 'int' }) imageWidth!: number; diff --git a/server/src/entities/face-search.entity.ts b/server/src/entities/face-search.entity.ts new file mode 100644 index 0000000000..3fd3c65f28 --- /dev/null +++ b/server/src/entities/face-search.entity.ts @@ -0,0 +1,21 @@ +import { AssetFaceEntity } from 'src/entities/asset-face.entity'; +import { asVector } from 'src/utils/database'; +import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; + +@Entity('face_search', { synchronize: false }) +export class FaceSearchEntity { + @OneToOne(() => AssetFaceEntity, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'faceId', referencedColumnName: 'id' }) + face?: AssetFaceEntity; + + @PrimaryColumn() + faceId!: string; + + @Index('face_index', { synchronize: false }) + @Column({ + type: 'float4', + array: true, + transformer: { from: (v) => JSON.parse(v), to: (v) => asVector(v) }, + }) + embedding!: number[]; +} diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 313f2dc269..cd3d74724b 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -8,6 +8,7 @@ import { AssetStackEntity } from 'src/entities/asset-stack.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { AuditEntity } from 'src/entities/audit.entity'; import { ExifEntity } from 'src/entities/exif.entity'; +import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { LibraryEntity } from 'src/entities/library.entity'; import { MemoryEntity } from 'src/entities/memory.entity'; @@ -34,6 +35,7 @@ export const entities = [ AssetJobStatusEntity, AuditEntity, ExifEntity, + FaceSearchEntity, GeodataPlacesEntity, MemoryEntity, MoveEntity, diff --git a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts new file mode 100644 index 0000000000..5bf3fcd97b --- /dev/null +++ b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts @@ -0,0 +1,54 @@ +import { getVectorExtension } from 'src/database.config'; +import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFaceSearchRelation1718486162779 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + if (getVectorExtension() === DatabaseExtension.VECTORS) { + await queryRunner.query(`SET search_path TO "$user", public, vectors`); + await queryRunner.query(`SET vectors.pgvector_compatibility=on`); + } + + await queryRunner.query(` + CREATE TABLE face_search ( + "faceId" uuid PRIMARY KEY REFERENCES asset_faces(id) ON DELETE CASCADE, + embedding vector(512) NOT NULL )`); + + await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET STORAGE EXTERNAL`); + await queryRunner.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET STORAGE EXTERNAL`); + + await queryRunner.query(` + INSERT INTO face_search("faceId", embedding) + SELECT id, embedding + FROM asset_faces faces`); + + await queryRunner.query(`ALTER TABLE asset_faces DROP COLUMN "embedding"`); + + await queryRunner.query(` + CREATE INDEX face_index ON face_search + USING hnsw (embedding vector_cosine_ops) + WITH (ef_construction = 300, m = 16)`); + } + + public async down(queryRunner: QueryRunner): Promise { + if (getVectorExtension() === DatabaseExtension.VECTORS) { + await queryRunner.query(`SET search_path TO "$user", public, vectors`); + await queryRunner.query(`SET vectors.pgvector_compatibility=on`); + } + + await queryRunner.query(`ALTER TABLE asset_faces ADD COLUMN "embedding" vector(512)`); + await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET STORAGE DEFAULT`); + await queryRunner.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET STORAGE DEFAULT`); + await queryRunner.query(` + UPDATE asset_faces + SET embedding = fs.embedding + FROM face_search fs + WHERE id = fs."faceId"`); + await queryRunner.query(`DROP TABLE face_search`); + + await queryRunner.query(` + CREATE INDEX face_index ON asset_faces + USING hnsw (embedding vector_cosine_ops) + WITH (ef_construction = 300, m = 16)`); + } +} diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 9efeae6248..987828a860 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -241,15 +241,16 @@ WITH "faces"."boundingBoxY1" AS "boundingBoxY1", "faces"."boundingBoxX2" AS "boundingBoxX2", "faces"."boundingBoxY2" AS "boundingBoxY2", - "faces"."embedding" <= > $1 AS "distance" + "search"."embedding" <= > $1 AS "distance" FROM "asset_faces" "faces" INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId" AND ("asset"."deletedAt" IS NULL) + INNER JOIN "face_search" "search" ON "search"."faceId" = "faces"."id" WHERE "asset"."ownerId" IN ($2) ORDER BY - "faces"."embedding" <= > $1 ASC + "search"."embedding" <= > $1 ASC LIMIT 100 ) diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index dc442e7017..fc9e76b0aa 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -98,7 +98,7 @@ export class DatabaseRepository implements IDatabaseRepository { } catch (error) { if (getVectorExtension() === DatabaseExtension.VECTORS) { this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`); - const table = index === VectorIndex.CLIP ? 'smart_search' : 'asset_faces'; + const table = index === VectorIndex.CLIP ? 'smart_search' : 'face_search'; const dimSize = await this.getDimSize(table); await this.dataSource.manager.transaction(async (manager) => { await this.setSearchPath(manager); diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 225a2edeca..36d742f8dc 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -14,7 +14,6 @@ import { PersonStatistics, UpdateFacesData, } from 'src/interfaces/person.interface'; -import { asVector } from 'src/utils/database'; import { Instrumentation } from 'src/utils/instrumentation'; import { Paginated, PaginationOptions, paginate } from 'src/utils/pagination'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; @@ -249,10 +248,8 @@ export class PersonRepository implements IPersonRepository { } async createFaces(entities: AssetFaceEntity[]): Promise { - const res = await this.assetFaceRepository.insert( - entities.map((entity) => ({ ...entity, embedding: () => asVector(entity.embedding, true) })), - ); - return res.identifiers.map((row) => row.id); + const res = await this.assetFaceRepository.save(entities); + return res.map((row) => row.id); } async update(entity: Partial): Promise { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index f0c5dcb364..439ccd099c 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -218,10 +218,11 @@ export class SearchRepository implements ISearchRepository { await this.assetRepository.manager.transaction(async (manager) => { const cte = manager .createQueryBuilder(AssetFaceEntity, 'faces') - .select('faces.embedding <=> :embedding', 'distance') + .select('search.embedding <=> :embedding', 'distance') .innerJoin('faces.asset', 'asset') + .innerJoin('faces.faceSearch', 'search') .where('asset.ownerId IN (:...userIds )') - .orderBy('faces.embedding <=> :embedding') + .orderBy('search.embedding <=> :embedding') .setParameters({ userIds, embedding: asVector(embedding) }); cte.limit(numResults); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index bf6fc8207e..eb0e3ad1e9 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -668,15 +668,18 @@ describe(PersonService.name, () => { machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); searchMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]); assetMock.getByIds.mockResolvedValue([assetStub.image]); + const faceId = 'face-id'; + cryptoMock.randomUUID.mockReturnValue(faceId); const face = { + id: faceId, assetId: 'asset-id', - embedding: [1, 2, 3, 4], boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200, imageHeight: 500, imageWidth: 400, + faceSearch: { faceId, embedding: [1, 2, 3, 4] }, }; await sut.handleDetectFaces({ id: assetStub.image.id }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 57940f3113..05034dc6f9 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -22,6 +22,7 @@ import { mapFaces, mapPerson, } from 'src/dtos/person.dto'; +import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { PersonPathType } from 'src/entities/move.entity'; import { PersonEntity } from 'src/entities/person.entity'; @@ -70,7 +71,7 @@ export class PersonService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, - @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.access = AccessCore.create(accessRepository); @@ -347,16 +348,21 @@ export class PersonService { if (faces.length > 0) { await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); - const mappedFaces = faces.map((face) => ({ - assetId: asset.id, - embedding: face.embedding, - imageHeight, - imageWidth, - boundingBoxX1: face.boundingBox.x1, - boundingBoxY1: face.boundingBox.y1, - boundingBoxX2: face.boundingBox.x2, - boundingBoxY2: face.boundingBox.y2, - })); + const mappedFaces: Partial[] = []; + for (const face of faces) { + const faceId = this.cryptoRepository.randomUUID(); + mappedFaces.push({ + id: faceId, + assetId: asset.id, + imageHeight, + imageWidth, + boundingBoxX1: face.boundingBox.x1, + boundingBoxY1: face.boundingBox.y1, + boundingBoxX2: face.boundingBox.x2, + boundingBoxY2: face.boundingBox.y2, + faceSearch: { faceId, embedding: face.embedding }, + }); + } const faceIds = await this.repository.createFaces(mappedFaces); await this.jobRepository.queueAll(faceIds.map((id) => ({ name: JobName.FACIAL_RECOGNITION, data: { id } }))); @@ -409,14 +415,19 @@ export class PersonService { const face = await this.repository.getFaceByIdWithAssets( id, - { person: true, asset: true }, - { id: true, personId: true, embedding: true }, + { person: true, asset: true, faceSearch: true }, + { id: true, personId: true, faceSearch: { embedding: true } }, ); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); return JobStatus.FAILED; } + if (!face.faceSearch?.embedding) { + this.logger.warn(`Face ${id} does not have an embedding`); + return JobStatus.FAILED; + } + if (face.personId) { this.logger.debug(`Face ${id} already has a person assigned`); return JobStatus.SKIPPED; @@ -424,7 +435,7 @@ export class PersonService { const matches = await this.smartInfoRepository.searchFaces({ userIds: [face.asset.ownerId], - embedding: face.embedding, + embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: machineLearning.facialRecognition.minFaces, }); @@ -448,7 +459,7 @@ export class PersonService { if (!personId) { const matchWithPerson = await this.smartInfoRepository.searchFaces({ userIds: [face.asset.ownerId], - embedding: face.embedding, + embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: 1, hasPerson: true, diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 5ecb5701ce..82935dd345 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -11,13 +11,13 @@ export const faceStub = { asset: assetStub.image, personId: personStub.withName.id, person: personStub.withName, - embedding: [1, 2, 3, 4], boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1, boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + faceSearch: { faceId: 'assetFaceId1', embedding: [1, 2, 3, 4] }, }), primaryFace1: Object.freeze>({ id: 'assetFaceId2', @@ -25,13 +25,13 @@ export const faceStub = { asset: assetStub.image, personId: personStub.primaryPerson.id, person: personStub.primaryPerson, - embedding: [1, 2, 3, 4], boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1, boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + faceSearch: { faceId: 'assetFaceId2', embedding: [1, 2, 3, 4] }, }), mergeFace1: Object.freeze>({ id: 'assetFaceId3', @@ -39,13 +39,13 @@ export const faceStub = { asset: assetStub.image, personId: personStub.mergePerson.id, person: personStub.mergePerson, - embedding: [1, 2, 3, 4], boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1, boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] }, }), mergeFace2: Object.freeze>({ id: 'assetFaceId4', @@ -53,13 +53,13 @@ export const faceStub = { asset: assetStub.image1, personId: personStub.mergePerson.id, person: personStub.mergePerson, - embedding: [1, 2, 3, 4], boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1, boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + faceSearch: { faceId: 'assetFaceId4', embedding: [1, 2, 3, 4] }, }), start: Object.freeze>({ id: 'assetFaceId5', @@ -67,13 +67,13 @@ export const faceStub = { asset: assetStub.image, personId: personStub.newThumbnail.id, person: personStub.newThumbnail, - embedding: [1, 2, 3, 4], boundingBoxX1: 5, boundingBoxY1: 5, boundingBoxX2: 505, boundingBoxY2: 505, imageHeight: 2880, imageWidth: 2160, + faceSearch: { faceId: 'assetFaceId5', embedding: [1, 2, 3, 4] }, }), middle: Object.freeze>({ id: 'assetFaceId6', @@ -81,13 +81,13 @@ export const faceStub = { asset: assetStub.image, personId: personStub.newThumbnail.id, person: personStub.newThumbnail, - embedding: [1, 2, 3, 4], boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200, imageHeight: 500, imageWidth: 400, + faceSearch: { faceId: 'assetFaceId6', embedding: [1, 2, 3, 4] }, }), end: Object.freeze>({ id: 'assetFaceId7', @@ -95,13 +95,13 @@ export const faceStub = { asset: assetStub.image, personId: personStub.newThumbnail.id, person: personStub.newThumbnail, - embedding: [1, 2, 3, 4], boundingBoxX1: 300, boundingBoxY1: 300, boundingBoxX2: 495, boundingBoxY2: 495, imageHeight: 500, imageWidth: 500, + faceSearch: { faceId: 'assetFaceId7', embedding: [1, 2, 3, 4] }, }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -109,13 +109,13 @@ export const faceStub = { asset: assetStub.image, personId: null, person: null, - embedding: [1, 2, 3, 4], boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1, boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + faceSearch: { faceId: 'assetFaceId8', embedding: [1, 2, 3, 4] }, }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -123,12 +123,12 @@ export const faceStub = { asset: assetStub.image, personId: null, person: null, - embedding: [1, 2, 3, 4], boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1, boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] }, }), }; From 8e373cee8d8679caaf10653d8b0bd2c83f903e43 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 16 Jun 2024 22:16:02 +0200 Subject: [PATCH 07/15] fix(server): include archived assets in forced thumbnail generation (#10409) --- server/src/services/media.service.spec.ts | 25 ++++++++++++++ server/src/services/media.service.ts | 2 +- server/test/fixtures/asset.stub.ts | 40 +++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index d5bda48fbb..d7addef737 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -132,6 +132,31 @@ describe(MediaService.name, () => { ]); }); + it('should queue archived assets when force is true', async () => { + assetMock.getAll.mockResolvedValue({ + items: [assetStub.archived], + hasNextPage: false, + }); + personMock.getAll.mockResolvedValue({ + items: [], + hasNextPage: false, + }); + + await sut.handleQueueGenerateThumbnails({ force: true }); + + expect(assetMock.getAll).toHaveBeenCalledWith( + { skip: 0, take: 1000 }, + expect.objectContaining({ withArchived: true }), + ); + expect(assetMock.getWithout).not.toHaveBeenCalled(); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.GENERATE_PREVIEW, + data: { id: assetStub.archived.id }, + }, + ]); + }); + it('should queue all people with missing thumbnail path', async () => { assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 6e5778c4df..33b80c9416 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -70,7 +70,7 @@ export class MediaService { async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { isVisible: true, withDeleted: true }) + ? this.assetRepository.getAll(pagination, { isVisible: true, withDeleted: true, withArchived: true }) : this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL); }); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 70075669e6..7ab4ef8962 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -249,6 +249,46 @@ export const assetStub = { duplicateId: null, }), + archived: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + previewPath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + thumbnailPath: '/uploads/user-id/webp/path.ext', + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: true, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as ExifEntity, + duplicateId: null, + }), + external: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', From a6e767e46dd1f3697ecd54196b41a5a201485c28 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:31:11 +0200 Subject: [PATCH 08/15] fix(web): selecting shared link expiration (#10437) --- .../create-shared-link-modal.svelte | 88 +++++++------------ .../shared-components/dropdown-button.svelte | 28 +++--- web/src/lib/i18n/en.json | 7 -- 3 files changed, 42 insertions(+), 81 deletions(-) diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index 1c1c757df0..512b415a6b 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -8,13 +8,14 @@ import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk'; import { mdiContentCopy, mdiLink } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; - import type { ImmichDropDownOption } from '../dropdown-button.svelte'; - import DropdownButton from '../dropdown-button.svelte'; + import DropdownButton, { type DropDownOption } from '../dropdown-button.svelte'; import { NotificationType, notificationController } from '../notification/notification'; import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte'; import SettingSwitch from '../settings/setting-switch.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { t } from 'svelte-i18n'; + import { locale } from '$lib/stores/preferences.store'; + import { DateTime, Duration } from 'luxon'; export let onClose: () => void; export let albumId: string | undefined = undefined; @@ -26,7 +27,7 @@ let allowDownload = true; let allowUpload = false; let showMetadata = true; - let expirationTime = ''; + let expirationOption: DropDownOption | undefined; let password = ''; let shouldChangeExpirationTime = false; let enablePassword = false; @@ -35,20 +36,27 @@ created: void; }>(); - const expiredDateOption: ImmichDropDownOption = { - default: $t('never'), - options: [ - $t('never'), - $t('durations.minutes', { values: { minutes: 30 } }), - $t('durations.hours', { values: { hours: 1 } }), - $t('durations.hours', { values: { hours: 6 } }), - $t('durations.days', { values: { days: 1 } }), - $t('durations.days', { values: { days: 7 } }), - $t('durations.days', { values: { days: 30 } }), - $t('durations.months', { values: { months: 3 } }), - $t('durations.years', { values: { years: 1 } }), - ], - }; + const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [ + [30, 'minutes'], + [1, 'hour'], + [6, 'hours'], + [1, 'day'], + [7, 'days'], + [30, 'days'], + [3, 'months'], + [1, 'year'], + ]; + + $: relativeTime = new Intl.RelativeTimeFormat($locale); + $: expiredDateOption = [ + { label: $t('never'), value: 0 }, + ...expirationOptions.map( + ([value, unit]): DropDownOption => ({ + label: relativeTime.format(value, unit), + value: Duration.fromObject({ [unit]: value }).toMillis(), + }), + ), + ]; $: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual; $: { @@ -74,9 +82,8 @@ } const handleCreateSharedLink = async () => { - const expirationTime = getExpirationTimeInMillisecond(); - const currentTime = Date.now(); - const expirationDate = expirationTime ? new Date(currentTime + expirationTime).toISOString() : undefined; + const expirationDate = + expirationOption && expirationOption.value > 0 ? DateTime.now().plus(expirationOption.value).toISO() : undefined; try { const data = await createSharedLink({ @@ -99,49 +106,14 @@ } }; - const getExpirationTimeInMillisecond = () => { - switch (expirationTime) { - case '30 minutes': { - return 30 * 60 * 1000; - } - case '1 hour': { - return 60 * 60 * 1000; - } - case '6 hours': { - return 6 * 60 * 60 * 1000; - } - case '1 day': { - return 24 * 60 * 60 * 1000; - } - case '7 days': { - return 7 * 24 * 60 * 60 * 1000; - } - case '30 days': { - return 30 * 24 * 60 * 60 * 1000; - } - case '3 months': { - return 30 * 24 * 60 * 60 * 3 * 1000; - } - case '1 year': { - return 30 * 24 * 60 * 60 * 12 * 1000; - } - default: { - return 0; - } - } - }; - const handleEditLink = async () => { if (!editingLink) { return; } try { - const expirationTime = getExpirationTimeInMillisecond(); - const currentTime = Date.now(); - const expirationDate: string | null = expirationTime - ? new Date(currentTime + expirationTime).toISOString() - : null; + const expirationDate = + expirationOption && expirationOption.value > 0 ? DateTime.now().plus(expirationOption.value).toISO() : null; await updateSharedLink({ id: editingLink.id, @@ -252,7 +224,7 @@
diff --git a/web/src/lib/components/shared-components/dropdown-button.svelte b/web/src/lib/components/shared-components/dropdown-button.svelte index 978fcb862c..450b3d5ce6 100644 --- a/web/src/lib/components/shared-components/dropdown-button.svelte +++ b/web/src/lib/components/shared-components/dropdown-button.svelte @@ -1,21 +1,15 @@ @@ -28,9 +22,11 @@ aria-expanded={isOpen} class="flex w-full place-items-center justify-between rounded-lg bg-gray-200 p-2 disabled:cursor-not-allowed disabled:bg-gray-600 dark:bg-gray-600 dark:disabled:bg-gray-300" > -
- {selected} -
+ {#if selected} +
+ {selected.label} +
+ {/if}
- {#each options.options as option} + {#each options as option} {/each}
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index ff6e97bd2c..e6a2b9a8d7 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -424,13 +424,6 @@ "duplicates": "Duplicates", "duplicates_description": "Resolve each group by indicating which, if any, are duplicates", "duration": "Duration", - "durations": { - "days": "{days, plural, one {day} other {{days, number} days}}", - "hours": "{hours, plural, one {hour} other {{hours, number} hours}}", - "minutes": "{minutes, plural, one {minute} other {{minutes, number} minutes}}", - "months": "{months, plural, one {month} other {{months, number} months}}", - "years": "{years, plural, one {year} other {{years, number} years}}" - }, "edit_album": "Edit album", "edit_avatar": "Edit avatar", "edit_date": "Edit date", From eb987c14c1aeb12fbfdd32806348ff042fc7f677 Mon Sep 17 00:00:00 2001 From: RanKKI Date: Tue, 18 Jun 2024 01:47:04 +1000 Subject: [PATCH 09/15] fix(mobile): search page (#10385) * refactor(search): hide people/places if empty * refactor(search): remove unused stack * refactor(search): fix dropdown menu's width * feat(search): show camera make/model vertically on mobile devices * fix: lint errors --- mobile/lib/pages/search/search.page.dart | 228 +++++++++--------- .../widgets/search/curated_people_row.dart | 137 +++++------ .../widgets/search/curated_places_row.dart | 155 ++++-------- mobile/lib/widgets/search/curated_row.dart | 66 ----- .../search/search_filter/camera_picker.dart | 141 +++++------ .../search/search_filter/common/dropdown.dart | 52 ++++ .../filter_bottom_sheet_scaffold.dart | 5 +- .../search/search_filter/location_picker.dart | 41 +--- .../widgets/search/search_map_thumbnail.dart | 76 ++++++ .../widgets/search/search_row_section.dart | 37 +++ .../lib/widgets/search/search_row_title.dart | 51 ++-- 11 files changed, 466 insertions(+), 523 deletions(-) delete mode 100644 mobile/lib/widgets/search/curated_row.dart create mode 100644 mobile/lib/widgets/search/search_filter/common/dropdown.dart create mode 100644 mobile/lib/widgets/search/search_map_thumbnail.dart create mode 100644 mobile/lib/widgets/search/search_row_section.dart diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 637bff42fc..2c578925c1 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -13,7 +13,7 @@ import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; import 'package:immich_mobile/widgets/search/curated_people_row.dart'; import 'package:immich_mobile/widgets/search/curated_places_row.dart'; import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; -import 'package:immich_mobile/widgets/search/search_row_title.dart'; +import 'package:immich_mobile/widgets/search/search_row_section.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -31,7 +31,7 @@ class SearchPage extends HookConsumerWidget { final curatedPeople = ref.watch(getAllPeopleProvider); final isMapEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map)); - double imageSize = math.min(context.width / 3, 150); + final double imageSize = math.min(context.width / 3, 150); TextStyle categoryTitleStyle = const TextStyle( fontWeight: FontWeight.w500, @@ -53,16 +53,15 @@ class SearchPage extends HookConsumerWidget { } buildPeople() { - return SizedBox( - height: imageSize, - child: curatedPeople.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (people) => Padding( - padding: const EdgeInsets.only( - left: 16, - top: 8, - ), + return curatedPeople.widgetWhen( + onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), + onData: (people) { + return SearchRowSection( + onViewAllPressed: () => context.pushRoute(const AllPeopleRoute()), + title: "search_page_people".tr(), + isEmpty: people.isEmpty, child: CuratedPeopleRow( + padding: const EdgeInsets.symmetric(horizontal: 16), content: people .map((e) => SearchCuratedContent(label: e.name, id: e.id)) .take(12) @@ -79,42 +78,46 @@ class SearchPage extends HookConsumerWidget { showNameEditModel(person.id, person.label), }, ), - ), - ), + ); + }, ); } buildPlaces() { - return SizedBox( - height: imageSize, - child: places.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (data) => CuratedPlacesRow( - isMapEnabled: isMapEnabled, - content: data, - imageSize: imageSize, - onTap: (content, index) { - context.pushRoute( - SearchInputRoute( - prefilter: SearchFilter( - people: {}, - location: SearchLocationFilter( - city: content.label, + return places.widgetWhen( + onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), + onData: (data) { + return SearchRowSection( + onViewAllPressed: () => context.pushRoute(const AllPlacesRoute()), + title: "search_page_places".tr(), + isEmpty: !isMapEnabled && data.isEmpty, + child: CuratedPlacesRow( + isMapEnabled: isMapEnabled, + content: data, + imageSize: imageSize, + onTap: (content, index) { + context.pushRoute( + SearchInputRoute( + prefilter: SearchFilter( + people: {}, + location: SearchLocationFilter( + city: content.label, + ), + camera: SearchCameraFilter(), + date: SearchDateFilter(), + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: AssetType.other, ), - camera: SearchCameraFilter(), - date: SearchDateFilter(), - display: SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - mediaType: AssetType.other, ), - ), - ); - }, - ), - ), + ); + }, + ), + ); + }, ); } @@ -160,88 +163,73 @@ class SearchPage extends HookConsumerWidget { return Scaffold( appBar: const ImmichAppBar(), - body: Stack( + body: ListView( children: [ - ListView( - children: [ - buildSearchButton(), - SearchRowTitle( - title: "search_page_people".tr(), - onViewAllPressed: () => - context.pushRoute(const AllPeopleRoute()), + buildSearchButton(), + const SizedBox(height: 8.0), + buildPeople(), + const SizedBox(height: 8.0), + buildPlaces(), + const SizedBox(height: 24.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'search_page_your_activity', + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, ), - buildPeople(), - SearchRowTitle( - title: "search_page_places".tr(), - onViewAllPressed: () => - context.pushRoute(const AllPlacesRoute()), - top: 0, + ).tr(), + ), + ListTile( + leading: Icon( + Icons.favorite_border_rounded, + color: categoryIconColor, + ), + title: + Text('search_page_favorites', style: categoryTitleStyle).tr(), + onTap: () => context.pushRoute(const FavoritesRoute()), + ), + const CategoryDivider(), + ListTile( + leading: Icon( + Icons.schedule_outlined, + color: categoryIconColor, + ), + title: Text( + 'search_page_recently_added', + style: categoryTitleStyle, + ).tr(), + onTap: () => context.pushRoute(const RecentlyAddedRoute()), + ), + const SizedBox(height: 24.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'search_page_categories', + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, ), - const SizedBox(height: 10.0), - buildPlaces(), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'search_page_your_activity', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - leading: Icon( - Icons.favorite_border_rounded, - color: categoryIconColor, - ), - title: Text('search_page_favorites', style: categoryTitleStyle) - .tr(), - onTap: () => context.pushRoute(const FavoritesRoute()), - ), - const CategoryDivider(), - ListTile( - leading: Icon( - Icons.schedule_outlined, - color: categoryIconColor, - ), - title: Text( - 'search_page_recently_added', - style: categoryTitleStyle, - ).tr(), - onTap: () => context.pushRoute(const RecentlyAddedRoute()), - ), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'search_page_categories', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - title: - Text('search_page_videos', style: categoryTitleStyle).tr(), - leading: Icon( - Icons.play_circle_outline, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllVideosRoute()), - ), - const CategoryDivider(), - ListTile( - title: Text( - 'search_page_motion_photos', - style: categoryTitleStyle, - ).tr(), - leading: Icon( - Icons.motion_photos_on_outlined, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllMotionPhotosRoute()), - ), - ], + ).tr(), + ), + ListTile( + title: Text('search_page_videos', style: categoryTitleStyle).tr(), + leading: Icon( + Icons.play_circle_outline, + color: categoryIconColor, + ), + onTap: () => context.pushRoute(const AllVideosRoute()), + ), + const CategoryDivider(), + ListTile( + title: Text( + 'search_page_motion_photos', + style: categoryTitleStyle, + ).tr(), + leading: Icon( + Icons.motion_photos_on_outlined, + color: categoryIconColor, + ), + onTap: () => context.pushRoute(const AllMotionPhotosRoute()), ), ], ), diff --git a/mobile/lib/widgets/search/curated_people_row.dart b/mobile/lib/widgets/search/curated_people_row.dart index 50a4c3b427..897cd454f6 100644 --- a/mobile/lib/widgets/search/curated_people_row.dart +++ b/mobile/lib/widgets/search/curated_people_row.dart @@ -2,11 +2,12 @@ import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/search/search_curated_content.model.dart'; -import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; class CuratedPeopleRow extends StatelessWidget { + static const double imageSize = 60.0; + final List content; final EdgeInsets? padding; @@ -24,88 +25,68 @@ class CuratedPeopleRow extends StatelessWidget { @override Widget build(BuildContext context) { - const imageSize = 60.0; - - // Guard empty [content] - if (content.isEmpty) { - // Return empty thumbnail - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - width: imageSize, - height: imageSize, - child: ThumbnailWithInfo( - textInfo: '', - onTap: () {}, - ), - ), - ), - ); - } - - return ListView.builder( - padding: padding, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - final person = content[index]; - final headers = { - "x-immich-user-token": Store.get(StoreKey.accessToken), - }; - return Padding( - padding: const EdgeInsets.only(right: 18.0), - child: SizedBox( - width: imageSize, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => onTap?.call(person, index), - child: SizedBox( - height: imageSize, - child: Material( - shape: const CircleBorder(side: BorderSide.none), - elevation: 3, - child: CircleAvatar( - maxRadius: imageSize / 2, - backgroundImage: NetworkImage( - getFaceThumbnailUrl(person.id), - headers: headers, - ), + return SizedBox( + height: imageSize + 30, + child: ListView.separated( + padding: padding, + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) => const SizedBox(width: 16), + itemBuilder: (context, index) { + final person = content[index]; + final headers = { + "x-immich-user-token": Store.get(StoreKey.accessToken), + }; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => onTap?.call(person, index), + child: SizedBox( + height: imageSize, + child: Material( + shape: const CircleBorder(side: BorderSide.none), + elevation: 3, + child: CircleAvatar( + maxRadius: imageSize / 2, + backgroundImage: NetworkImage( + getFaceThumbnailUrl(person.id), + headers: headers, ), ), ), ), - if (person.label == "") - GestureDetector( - onTap: () => onNameTap?.call(person, index), - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - "exif_bottom_sheet_person_add_person", - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ).tr(), - ), - ) - else - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - person.label, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelLarge, - ), - ), - ], - ), + ), + const SizedBox(height: 8), + _buildPersonLabel(context, person, index), + ], + ); + }, + itemCount: content.length, + ), + ); + } + + Widget _buildPersonLabel( + BuildContext context, + SearchCuratedContent person, + int index, + ) { + if (person.label.isEmpty) { + return GestureDetector( + onTap: () => onNameTap?.call(person, index), + child: Text( + "exif_bottom_sheet_person_add_person", + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, ), - ); - }, - itemCount: content.length, + ).tr(), + ); + } + return Text( + person.label, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelLarge, ); } } diff --git a/mobile/lib/widgets/search/curated_places_row.dart b/mobile/lib/widgets/search/curated_places_row.dart index babb20035a..4488f9cb7d 100644 --- a/mobile/lib/widgets/search/curated_places_row.dart +++ b/mobile/lib/widgets/search/curated_places_row.dart @@ -1,135 +1,64 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; -import 'package:immich_mobile/widgets/search/curated_row.dart'; +import 'package:immich_mobile/models/search/search_curated_content.model.dart'; +import 'package:immich_mobile/widgets/search/search_map_thumbnail.dart'; import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -class CuratedPlacesRow extends CuratedRow { - final bool isMapEnabled; +class CuratedPlacesRow extends StatelessWidget { const CuratedPlacesRow({ super.key, - required super.content, + required this.content, + required this.imageSize, this.isMapEnabled = true, - super.imageSize, - super.onTap, + this.onTap, }); + final bool isMapEnabled; + final List content; + final double imageSize; + + /// Callback with the content and the index when tapped + final Function(SearchCuratedContent, int)? onTap; + @override Widget build(BuildContext context) { // Calculating the actual index of the content based on the whether map is enabled or not. // If enabled, inject map as the first item in the list (index 0) and so the actual content will start from index 1 final int actualContentIndex = isMapEnabled ? 1 : 0; - Widget buildMapThumbnail() { - return GestureDetector( - onTap: () => context.pushRoute( - const MapRoute(), - ), - child: SizedBox.square( - dimension: imageSize, - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.only(right: 10.0), - child: MapThumbnail( - zoom: 2, - centre: const LatLng( - 47, - 5, - ), - height: imageSize, - width: imageSize, - showAttribution: false, - ), - ), - Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Colors.black, - gradient: LinearGradient( - begin: FractionalOffset.topCenter, - end: FractionalOffset.bottomCenter, - colors: [ - Colors.blueGrey.withOpacity(0.0), - Colors.black.withOpacity(0.4), - ], - stops: const [0.0, 0.4], - ), - ), - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(bottom: 10), - child: const Text( - "search_page_your_map", - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ).tr(), - ), - ), - ], - ), - ), - ); - } - // Return empty thumbnail - if (!isMapEnabled && content.isEmpty) { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( + return SizedBox( + height: imageSize, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + itemBuilder: (context, index) { + // Injecting Map thumbnail as the first element + if (isMapEnabled && index == 0) { + return SearchMapThumbnail( + size: imageSize, + ); + } + final actualIndex = index - actualContentIndex; + final object = content[actualIndex]; + final thumbnailRequestUrl = + '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail'; + return SizedBox( width: imageSize, height: imageSize, - child: ThumbnailWithInfo( - textInfo: '', - onTap: () {}, + child: Padding( + padding: const EdgeInsets.only(right: 10.0), + child: ThumbnailWithInfo( + imageUrl: thumbnailRequestUrl, + textInfo: object.label, + onTap: () => onTap?.call(object, actualIndex), + ), ), - ), - ), - ); - } - - return ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 16, + ); + }, + itemCount: content.length + actualContentIndex, ), - itemBuilder: (context, index) { - // Injecting Map thumbnail as the first element - if (isMapEnabled && index == 0) { - return buildMapThumbnail(); - } - final actualIndex = index - actualContentIndex; - final object = content[actualIndex]; - final thumbnailRequestUrl = - '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail'; - return SizedBox( - width: imageSize, - height: imageSize, - child: Padding( - padding: const EdgeInsets.only(right: 10.0), - child: ThumbnailWithInfo( - imageUrl: thumbnailRequestUrl, - textInfo: object.label, - onTap: () => onTap?.call(object, actualIndex), - ), - ), - ); - }, - itemCount: content.length + actualContentIndex, ); } } diff --git a/mobile/lib/widgets/search/curated_row.dart b/mobile/lib/widgets/search/curated_row.dart deleted file mode 100644 index 96537f65b4..0000000000 --- a/mobile/lib/widgets/search/curated_row.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:immich_mobile/models/search/search_curated_content.model.dart'; -import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; - -class CuratedRow extends StatelessWidget { - final List content; - final double imageSize; - - /// Callback with the content and the index when tapped - final Function(SearchCuratedContent, int)? onTap; - - const CuratedRow({ - super.key, - required this.content, - this.imageSize = 200, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - // Guard empty [content] - if (content.isEmpty) { - // Return empty thumbnail - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - width: imageSize, - height: imageSize, - child: ThumbnailWithInfo( - textInfo: '', - onTap: () {}, - ), - ), - ), - ); - } - - return ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - itemBuilder: (context, index) { - final object = content[index]; - final thumbnailRequestUrl = - '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail'; - return SizedBox( - width: imageSize, - height: imageSize, - child: Padding( - padding: const EdgeInsets.only(right: 4.0), - child: ThumbnailWithInfo( - imageUrl: thumbnailRequestUrl, - textInfo: object.label, - onTap: () => onTap?.call(object, index), - ), - ), - ); - }, - itemCount: content.length, - ); - } -} diff --git a/mobile/lib/widgets/search/search_filter/camera_picker.dart b/mobile/lib/widgets/search/search_filter/camera_picker.dart index ea347141a7..2e5618c9e0 100644 --- a/mobile/lib/widgets/search/search_filter/camera_picker.dart +++ b/mobile/lib/widgets/search/search_filter/camera_picker.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/search_filter.provider.dart'; +import 'package:immich_mobile/widgets/search/search_filter/common/dropdown.dart'; import 'package:openapi/api.dart'; class CameraPicker extends HookConsumerWidget { @@ -12,6 +13,7 @@ class CameraPicker extends HookConsumerWidget { final Function(Map) onSelect; final SearchCameraFilter? filter; + @override Widget build(BuildContext context, WidgetRef ref) { final makeTextController = useTextEditingController(text: filter?.make); @@ -32,90 +34,73 @@ class CameraPicker extends HookConsumerWidget { ), ); - final inputDecorationTheme = InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - contentPadding: const EdgeInsets.only(left: 16), + final makeWidget = SearchDropdown( + dropdownMenuEntries: switch (make) { + AsyncError() => [], + AsyncData(:final value) => value + .map( + (e) => DropdownMenuEntry( + value: e, + label: e, + ), + ) + .toList(), + _ => [], + }, + label: const Text('search_filter_camera_make').tr(), + controller: makeTextController, + leadingIcon: const Icon(Icons.photo_camera_rounded), + onSelected: (value) { + selectedMake.value = value.toString(); + onSelect({ + 'make': selectedMake.value, + 'model': selectedModel.value, + }); + }, ); - final menuStyle = MenuStyle( - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - ), + final modelWidget = SearchDropdown( + dropdownMenuEntries: switch (models) { + AsyncError() => [], + AsyncData(:final value) => value + .map( + (e) => DropdownMenuEntry( + value: e, + label: e, + ), + ) + .toList(), + _ => [], + }, + label: const Text('search_filter_camera_model').tr(), + controller: modelTextController, + leadingIcon: const Icon(Icons.camera), + onSelected: (value) { + selectedModel.value = value.toString(); + onSelect({ + 'make': selectedMake.value, + 'model': selectedModel.value, + }); + }, ); - return Container( - padding: const EdgeInsets.only( - // bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + if (context.isMobile) { + return Column( children: [ - DropdownMenu( - dropdownMenuEntries: switch (make) { - AsyncError() => [], - AsyncData(:final value) => value - .map( - (e) => DropdownMenuEntry( - value: e, - label: e, - ), - ) - .toList(), - _ => [], - }, - width: context.width * 0.45, - menuHeight: 400, - label: const Text('search_filter_camera_make').tr(), - inputDecorationTheme: inputDecorationTheme, - controller: makeTextController, - menuStyle: menuStyle, - leadingIcon: const Icon(Icons.photo_camera_rounded), - trailingIcon: const Icon(Icons.arrow_drop_down_rounded), - selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), - onSelected: (value) { - selectedMake.value = value.toString(); - onSelect({ - 'make': selectedMake.value, - 'model': selectedModel.value, - }); - }, - ), - DropdownMenu( - dropdownMenuEntries: switch (models) { - AsyncError() => [], - AsyncData(:final value) => value - .map( - (e) => DropdownMenuEntry( - value: e, - label: e, - ), - ) - .toList(), - _ => [], - }, - width: context.width * 0.45, - menuHeight: 400, - label: const Text('search_filter_camera_model').tr(), - inputDecorationTheme: inputDecorationTheme, - controller: modelTextController, - menuStyle: menuStyle, - leadingIcon: const Icon(Icons.camera), - trailingIcon: const Icon(Icons.arrow_drop_down_rounded), - selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), - onSelected: (value) { - selectedModel.value = value.toString(); - onSelect({ - 'make': selectedMake.value, - 'model': selectedModel.value, - }); - }, - ), + makeWidget, + const SizedBox(height: 8), + modelWidget, ], - ), + ); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded(child: makeWidget), + const SizedBox(width: 16), + Expanded(child: modelWidget), + ], ); } } diff --git a/mobile/lib/widgets/search/search_filter/common/dropdown.dart b/mobile/lib/widgets/search/search_filter/common/dropdown.dart new file mode 100644 index 0000000000..55b54ce46a --- /dev/null +++ b/mobile/lib/widgets/search/search_filter/common/dropdown.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class SearchDropdown extends StatelessWidget { + const SearchDropdown({ + super.key, + required this.dropdownMenuEntries, + required this.controller, + this.onSelected, + this.label, + this.leadingIcon, + }); + + final List> dropdownMenuEntries; + final TextEditingController controller; + final void Function(T?)? onSelected; + final Widget? label; + final Widget? leadingIcon; + + @override + Widget build(BuildContext context) { + final inputDecorationTheme = InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + contentPadding: const EdgeInsets.only(left: 16), + ); + + final menuStyle = MenuStyle( + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + ); + + return LayoutBuilder( + builder: (context, constraints) { + return DropdownMenu( + leadingIcon: leadingIcon, + width: constraints.maxWidth, + dropdownMenuEntries: dropdownMenuEntries, + label: label, + inputDecorationTheme: inputDecorationTheme, + menuStyle: menuStyle, + trailingIcon: const Icon(Icons.arrow_drop_down_rounded), + selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), + onSelected: onSelected, + ); + }, + ); + } +} diff --git a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart index d636c8c7ce..95dc8b60e1 100644 --- a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart +++ b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart @@ -38,7 +38,10 @@ class FilterBottomSheetScaffold extends StatelessWidget { style: context.textTheme.headlineSmall, ), ), - buildChildWidget(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: buildChildWidget(), + ), Padding( padding: const EdgeInsets.all(8.0), child: Row( diff --git a/mobile/lib/widgets/search/search_filter/location_picker.dart b/mobile/lib/widgets/search/search_filter/location_picker.dart index 3aee57c3ca..595d380300 100644 --- a/mobile/lib/widgets/search/search_filter/location_picker.dart +++ b/mobile/lib/widgets/search/search_filter/location_picker.dart @@ -2,9 +2,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/search_filter.provider.dart'; +import 'package:immich_mobile/widgets/search/search_filter/common/dropdown.dart'; import 'package:openapi/api.dart'; class LocationPicker extends HookConsumerWidget { @@ -48,24 +48,9 @@ class LocationPicker extends HookConsumerWidget { ), ); - final inputDecorationTheme = InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - contentPadding: const EdgeInsets.only(left: 16), - ); - - final menuStyle = MenuStyle( - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - ), - ); - return Column( children: [ - DropdownMenu( + SearchDropdown( dropdownMenuEntries: switch (countries) { AsyncError() => [], AsyncData(:final value) => value @@ -78,14 +63,8 @@ class LocationPicker extends HookConsumerWidget { .toList(), _ => [], }, - menuHeight: 400, - width: context.width * 0.9, label: const Text('search_filter_location_country').tr(), - inputDecorationTheme: inputDecorationTheme, - menuStyle: menuStyle, controller: countryTextController, - trailingIcon: const Icon(Icons.arrow_drop_down_rounded), - selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), onSelected: (value) { if (value.toString() == selectedCountry.value) { return; @@ -103,7 +82,7 @@ class LocationPicker extends HookConsumerWidget { const SizedBox( height: 16, ), - DropdownMenu( + SearchDropdown( dropdownMenuEntries: switch (states) { AsyncError() => [], AsyncData(:final value) => value @@ -116,14 +95,8 @@ class LocationPicker extends HookConsumerWidget { .toList(), _ => [], }, - menuHeight: 400, - width: context.width * 0.9, label: const Text('search_filter_location_state').tr(), - inputDecorationTheme: inputDecorationTheme, - menuStyle: menuStyle, controller: stateTextController, - trailingIcon: const Icon(Icons.arrow_drop_down_rounded), - selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), onSelected: (value) { if (value.toString() == selectedState.value) { return; @@ -140,7 +113,7 @@ class LocationPicker extends HookConsumerWidget { const SizedBox( height: 16, ), - DropdownMenu( + SearchDropdown( dropdownMenuEntries: switch (cities) { AsyncError() => [], AsyncData(:final value) => value @@ -153,14 +126,8 @@ class LocationPicker extends HookConsumerWidget { .toList(), _ => [], }, - menuHeight: 400, - width: context.width * 0.9, label: const Text('search_filter_location_city').tr(), - inputDecorationTheme: inputDecorationTheme, - menuStyle: menuStyle, controller: cityTextController, - trailingIcon: const Icon(Icons.arrow_drop_down_rounded), - selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), onSelected: (value) { selectedCity.value = value.toString(); onSelected({ diff --git a/mobile/lib/widgets/search/search_map_thumbnail.dart b/mobile/lib/widgets/search/search_map_thumbnail.dart new file mode 100644 index 0000000000..f0c36a8192 --- /dev/null +++ b/mobile/lib/widgets/search/search_map_thumbnail.dart @@ -0,0 +1,76 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class SearchMapThumbnail extends StatelessWidget { + const SearchMapThumbnail({ + super.key, + this.size = 60.0, + }); + + final double size; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => context.pushRoute( + const MapRoute(), + ), + child: SizedBox.square( + dimension: size, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: MapThumbnail( + zoom: 2, + centre: const LatLng( + 47, + 5, + ), + height: size, + width: size, + showAttribution: false, + ), + ), + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Colors.black, + gradient: LinearGradient( + begin: FractionalOffset.topCenter, + end: FractionalOffset.bottomCenter, + colors: [ + Colors.blueGrey.withOpacity(0.0), + Colors.black.withOpacity(0.4), + ], + stops: const [0.0, 0.4], + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: const Text( + "search_page_your_map", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ).tr(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/search/search_row_section.dart b/mobile/lib/widgets/search/search_row_section.dart new file mode 100644 index 0000000000..352c7f6a40 --- /dev/null +++ b/mobile/lib/widgets/search/search_row_section.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/widgets/search/search_row_title.dart'; + +class SearchRowSection extends StatelessWidget { + const SearchRowSection({ + super.key, + required this.onViewAllPressed, + required this.title, + this.isEmpty = false, + required this.child, + }); + + final Function() onViewAllPressed; + final String title; + final bool isEmpty; + final Widget child; + + @override + Widget build(BuildContext context) { + if (isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SearchRowTitle( + onViewAllPressed: onViewAllPressed, + title: title, + ), + ), + child, + ], + ); + } +} diff --git a/mobile/lib/widgets/search/search_row_title.dart b/mobile/lib/widgets/search/search_row_title.dart index 830bc94c98..4fa0d1f854 100644 --- a/mobile/lib/widgets/search/search_row_title.dart +++ b/mobile/lib/widgets/search/search_row_title.dart @@ -3,45 +3,36 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; class SearchRowTitle extends StatelessWidget { - final Function() onViewAllPressed; - final String title; - final double top; - const SearchRowTitle({ super.key, required this.onViewAllPressed, required this.title, - this.top = 12, }); + final Function() onViewAllPressed; + final String title; + @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - left: 16.0, - right: 16.0, - top: top, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + TextButton( + onPressed: onViewAllPressed, + child: Text( + 'search_page_view_all_button', + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, ), - ), - TextButton( - onPressed: onViewAllPressed, - child: Text( - 'search_page_view_all_button', - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ).tr(), - ), - ], - ), + ).tr(), + ), + ], ); } } From 7ce87abc9574dc070dc3ab045ae0aa4746da0547 Mon Sep 17 00:00:00 2001 From: RanKKI Date: Tue, 18 Jun 2024 01:48:58 +1000 Subject: [PATCH 10/15] fix(mobile): my location button on maps not visible due to bottom padding (#10384) fix(maps): my location button not visible due to bottom padding --- mobile/lib/pages/search/map/map.page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index 1cfa7ada0e..e686994557 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -298,7 +298,7 @@ class MapPage extends HookConsumerWidget { ), Positioned( right: 0, - bottom: 30, + bottom: MediaQuery.of(context).padding.bottom + 16, child: ElevatedButton( onPressed: onZoomToLocation, style: ElevatedButton.styleFrom( From 29e4666dfad852e1386f851382c7e2ebba46f8ab Mon Sep 17 00:00:00 2001 From: RanKKI Date: Tue, 18 Jun 2024 03:01:02 +1000 Subject: [PATCH 11/15] fix(mobile): asset description is not shown on the sheet when opened for the first time (#10377) * fix: invalidate asset's description when asset details changed * refactor(exif-sheet): use description from exif instead * refactor(asset-description): remove asset_description.provider * fix(asset-description): set is empty based on exifInfo.description * chore: rename service to provider --- .../asset_description.provider.dart | 87 ------------------- .../services/asset_description.service.dart | 43 ++++----- .../asset_viewer/description_input.dart | 19 ++-- .../exif_sheet/exif_bottom_sheet.dart | 6 +- 4 files changed, 32 insertions(+), 123 deletions(-) delete mode 100644 mobile/lib/providers/asset_viewer/asset_description.provider.dart diff --git a/mobile/lib/providers/asset_viewer/asset_description.provider.dart b/mobile/lib/providers/asset_viewer/asset_description.provider.dart deleted file mode 100644 index 11a622cad1..0000000000 --- a/mobile/lib/providers/asset_viewer/asset_description.provider.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/asset_description.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; - -class AssetDescriptionNotifier extends StateNotifier { - final Isar _db; - final AssetDescriptionService _service; - final Asset _asset; - - AssetDescriptionNotifier( - this._db, - this._service, - this._asset, - ) : super('') { - _fetchLocalDescription(); - _fetchRemoteDescription(); - } - - String get description => state; - - /// Fetches the local database value for description - /// and writes it to [state] - void _fetchLocalDescription() async { - final localExifId = _asset.exifInfo?.id; - - // Guard [localExifId] null - if (localExifId == null) { - return; - } - - // Subscribe to local changes - final exifInfo = await _db.exifInfos.get(localExifId); - - // Guard - if (exifInfo?.description == null) { - return; - } - - state = exifInfo!.description!; - } - - /// Fetches the remote value and sets the state - void _fetchRemoteDescription() async { - final remoteAssetId = _asset.remoteId; - final localExifId = _asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (remoteAssetId == null || localExifId == null) { - return; - } - - // Reads the latest from the remote and writes it to DB in the service - final latest = await _service.readLatest(remoteAssetId, localExifId); - - state = latest; - } - - /// Sets the description to [description] - /// Uses the service to set the asset value - Future setDescription(String description) async { - state = description; - - final remoteAssetId = _asset.remoteId; - final localExifId = _asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (remoteAssetId == null || localExifId == null) { - return; - } - - return _service.setDescription(description, remoteAssetId, localExifId); - } -} - -final assetDescriptionProvider = StateNotifierProvider.autoDispose - .family( - (ref, asset) => AssetDescriptionNotifier( - ref.watch(dbProvider), - ref.watch(assetDescriptionServiceProvider), - asset, - ), -); diff --git a/mobile/lib/services/asset_description.service.dart b/mobile/lib/services/asset_description.service.dart index 3b9bc5d567..66437d61e2 100644 --- a/mobile/lib/services/asset_description.service.dart +++ b/mobile/lib/services/asset_description.service.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -12,46 +13,36 @@ class AssetDescriptionService { final Isar _db; final ApiService _api; - setDescription( - String description, - String remoteAssetId, - int localExifId, + Future setDescription( + Asset asset, + String newDescription, ) async { + final remoteAssetId = asset.remoteId; + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + final result = await _api.assetsApi.updateAsset( remoteAssetId, - UpdateAssetDto(description: description), + UpdateAssetDto(description: newDescription), ); - if (result?.exifInfo?.description != null) { + final description = result?.exifInfo?.description; + + if (description != null) { var exifInfo = await _db.exifInfos.get(localExifId); if (exifInfo != null) { - exifInfo.description = result!.exifInfo!.description; + exifInfo.description = description; await _db.writeTxn( () => _db.exifInfos.put(exifInfo), ); } } } - - Future readLatest(String assetRemoteId, int localExifId) async { - final latestAssetFromServer = - await _api.assetsApi.getAssetInfo(assetRemoteId); - final localExifInfo = await _db.exifInfos.get(localExifId); - - if (latestAssetFromServer != null && localExifInfo != null) { - localExifInfo.description = - latestAssetFromServer.exifInfo?.description ?? ''; - - await _db.writeTxn( - () => _db.exifInfos.put(localExifInfo), - ); - - return localExifInfo.description!; - } - - return ""; - } } final assetDescriptionServiceProvider = Provider( diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart index bd67bf1d34..7422e43335 100644 --- a/mobile/lib/widgets/asset_viewer/description_input.dart +++ b/mobile/lib/widgets/asset_viewer/description_input.dart @@ -2,10 +2,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_description.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/asset_description.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; @@ -13,9 +14,11 @@ class DescriptionInput extends HookConsumerWidget { DescriptionInput({ super.key, required this.asset, + this.exifInfo, }); final Asset asset; + final ExifInfo? exifInfo; final Logger _log = Logger('DescriptionInput'); @override @@ -25,25 +28,25 @@ class DescriptionInput extends HookConsumerWidget { final focusNode = useFocusNode(); final isFocus = useState(false); final isTextEmpty = useState(controller.text.isEmpty); - final descriptionProvider = - ref.watch(assetDescriptionProvider(asset).notifier); - final description = ref.watch(assetDescriptionProvider(asset)); + final descriptionProvider = ref.watch(assetDescriptionServiceProvider); + final owner = ref.watch(currentUserProvider); final hasError = useState(false); useEffect( () { - controller.text = description; - isTextEmpty.value = description.isEmpty; + controller.text = exifInfo?.description ?? ''; + isTextEmpty.value = exifInfo?.description?.isEmpty ?? true; return null; }, - [description], + [exifInfo?.description], ); submitDescription(String description) async { hasError.value = false; try { await descriptionProvider.setDescription( + asset, description, ); } catch (error, stack) { @@ -85,7 +88,7 @@ class DescriptionInput extends HookConsumerWidget { isFocus.value = false; focusNode.unfocus(); - if (description != controller.text) { + if (exifInfo?.description != controller.text) { await submitDescription(controller.text); } }, diff --git a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart b/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart index 9a9de304ca..8d1694adfb 100644 --- a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart +++ b/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart @@ -73,7 +73,8 @@ class ExifBottomSheet extends HookConsumerWidget { child: Column( children: [ dateWidget, - if (asset.isRemote) DescriptionInput(asset: asset), + if (asset.isRemote) + DescriptionInput(asset: asset, exifInfo: exifInfo), ], ), ), @@ -132,7 +133,8 @@ class ExifBottomSheet extends HookConsumerWidget { child: Column( children: [ dateWidget, - if (asset.isRemote) DescriptionInput(asset: asset), + if (asset.isRemote) + DescriptionInput(asset: asset, exifInfo: exifInfo), Padding( padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16.0), child: ExifLocation( From 38e26fd67c99c4353eeafe8ae52c4ddb2f82a5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Guillaume=20Lemesre?= Date: Mon, 17 Jun 2024 13:08:31 -0400 Subject: [PATCH 12/15] chore: update Unraid Docker-Compose documentation to reflect missing healthcheck start_interval parameter from Docker Engine v24.0.9. (#10406) * Update Unraid Docker-Compose documentation to reflect missing healthcheck start_interval parameter from Docker Engine v24.0.9. Unraid v6.12.10 uses Docker Engine v24.0.9, which does not support setting a start_interval parameter, used by the database container. Added info to the documentation to bypass this while retaining the initial health check interval. * Fixed Markdown formatting. * Removed info box formatting issue. Moved the information about Unraid's Docker Engine version to section 4 of the installation instructions, instead of trying to use an info box that broke the formatting. * fix format --------- Co-authored-by: Alex --- docs/docs/install/unraid.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/docs/install/unraid.md b/docs/docs/install/unraid.md index 5d39b6d487..67de980186 100644 --- a/docs/docs/install/unraid.md +++ b/docs/docs/install/unraid.md @@ -27,7 +27,7 @@ For more information about setting up the community image see [here](https://git :::info -- Guide was written using Unraid v6.12.10 +- Guide was written using Unraid v6.12.10. - Requires you to have installed the plugin: [Docker Compose Manager](https://forums.unraid.net/topic/114415-plugin-docker-compose-manager/) - An Unraid share created for your images - There has been a [report](https://forums.unraid.net/topic/130006-errortraps-traps-node27707-trap-invalid-opcode-ip14fcfc8d03c0-sp7fff32889dd8-more/#comment-1189395) of this not working if your Unraid server doesn't support AVX _(e.g. using a T610)_ @@ -46,7 +46,8 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich" /> 3. Select the cog ⚙️ next to Immich then click "**Edit Stack**" -4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) file into the Unraid editor. Remove any text that may be in the text area by default. +4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) file into the Unraid editor. Remove any text that may be in the text area by default. Note that Unraid v6.12.10 uses version 24.0.9 of the Docker Engine, which does not support healthcheck `start_interval` as defined in the `database` service of the Docker compose file (version 25 or higher is needed). This parameter defines an initial waiting period before starting health checks, to give the container time to start up. Commenting out the `start_interval` and `start_period` parameters will allow the containers to start up normally. The only downside to this is that the database container will not receive an initial health check until `interval` time has passed. +
Using an existing Postgres container? Click me! Otherwise proceed to step 5.
    @@ -70,6 +71,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich" />
+ 5. Click "**Save Changes**", you will be promoted to edit stack UI labels, just leave this blank and click "**Ok**" 6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**" 7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following: From 1b67ea2d913374ecae73e44e731d3c4cf309b6b9 Mon Sep 17 00:00:00 2001 From: Stephen Smith Date: Mon, 17 Jun 2024 13:11:11 -0400 Subject: [PATCH 13/15] chore(server): update exiftool and migrate off deprecated method signatures (#10367) * chore(server): update exiftool and migrate off deprecated method signatures * chore(server): update exiftool-vendored to 27.0.0 * chore(server): switch away from deprecated exiftool method signatures - options now includes read/writeArgs making the deprecated signatures with args array redundant - switch read call from file,args,options to file,options - switch write call from file,tags,args to file,tags,options * chore(server): move largefilesupport flags into exiftool constructor - options now includes read/writeArgs making it available to be set globally in constructor - switches back to instantiating an instance of exiftool * chore(server): consolidate exiftool config into constructor along with writeArgs * chore(server): move exiftool instantiation into MetadataRepository constructor --- server/package-lock.json | 15 ++++--- server/package.json | 2 +- .../src/repositories/metadata.repository.ts | 43 +++++++++---------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 6cc3572ec4..1ca61d6ed7 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -34,7 +34,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", - "exiftool-vendored": "~26.2.0", + "exiftool-vendored": "~27.0.0", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", @@ -9127,9 +9127,10 @@ } }, "node_modules/exiftool-vendored": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.2.0.tgz", - "integrity": "sha512-7P6jQ944or7ic2SJzW+uaWK4TLDXlaCppHrBayl4MpIrVcEeQjiQTez4/oOH0wULIRu4j4H6Xruz4SLrDaafUg==", + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-27.0.0.tgz", + "integrity": "sha512-/jHX8Jjadj0YJzpqnuBo1Yy2ln2hnRbBIc+3jcVOLQ6qhHEKsLRlfJ145Ghn7k/EcnfpDzVX3V8AUCTC8juTow==", + "license": "MIT", "dependencies": { "@photostructure/tz-lookup": "^10.0.0", "@types/luxon": "^3.4.2", @@ -22600,9 +22601,9 @@ } }, "exiftool-vendored": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.2.0.tgz", - "integrity": "sha512-7P6jQ944or7ic2SJzW+uaWK4TLDXlaCppHrBayl4MpIrVcEeQjiQTez4/oOH0wULIRu4j4H6Xruz4SLrDaafUg==", + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-27.0.0.tgz", + "integrity": "sha512-/jHX8Jjadj0YJzpqnuBo1Yy2ln2hnRbBIc+3jcVOLQ6qhHEKsLRlfJ145Ghn7k/EcnfpDzVX3V8AUCTC8juTow==", "requires": { "@photostructure/tz-lookup": "^10.0.0", "@types/luxon": "^3.4.2", diff --git a/server/package.json b/server/package.json index 6625555bb0..1633302ea3 100644 --- a/server/package.json +++ b/server/package.json @@ -60,7 +60,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", - "exiftool-vendored": "~26.2.0", + "exiftool-vendored": "~27.0.0", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 0b32233f6a..a7c6bd13bb 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DefaultReadTaskOptions, Tags, exiftool } from 'exiftool-vendored'; +import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; import { DummyValue, GenerateSql } from 'src/decorators'; import { ExifEntity } from 'src/entities/exif.entity'; @@ -20,40 +20,39 @@ export class MetadataRepository implements IMetadataRepository { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(MetadataRepository.name); + this.exiftool = new ExifTool({ + defaultVideosToUTC: true, + backfillTimezones: true, + inferTimezoneFromDatestamps: true, + useMWG: true, + numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength'], + /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ + geoTz: (lat, lon) => geotz.find(lat, lon)[0], + // Enable exiftool LFS to parse metadata for files larger than 2GB. + readArgs: ['-api', 'largefilesupport=1'], + writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], + }); } + private exiftool: ExifTool; async teardown() { - await exiftool.end(); + await this.exiftool.end(); } readTags(path: string): Promise { - return exiftool - .read(path, undefined, { - ...DefaultReadTaskOptions, - - // Enable exiftool LFS to parse metadata for files larger than 2GB. - optionalArgs: ['-api', 'largefilesupport=1'], - defaultVideosToUTC: true, - backfillTimezones: true, - inferTimezoneFromDatestamps: true, - useMWG: true, - numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength'], - /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ - geoTz: (lat, lon) => geotz.find(lat, lon)[0], - }) - .catch((error) => { - this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack); - return null; - }) as Promise; + return this.exiftool.read(path).catch((error) => { + this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack); + return null; + }) as Promise; } extractBinaryTag(path: string, tagName: string): Promise { - return exiftool.extractBinaryTagToBuffer(tagName, path); + return this.exiftool.extractBinaryTagToBuffer(tagName, path); } async writeTags(path: string, tags: Partial): Promise { try { - await exiftool.write(path, tags, ['-overwrite_original']); + await this.exiftool.write(path, tags); } catch (error) { this.logger.warn(`Error writing exif data (${path}): ${error}`); } From e8994d9ffd68c5efbeaa1f75fa9de484584d424a Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 17 Jun 2024 11:44:25 -0700 Subject: [PATCH 14/15] fix(web): confirm button is disabled if two dialogs are shown subsequently (#10440) --- .../shared-components/dialog/confirm-dialog.svelte | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte index e5b35859f2..6acd819533 100644 --- a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte +++ b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte @@ -16,10 +16,7 @@ export let onCancel: () => void; export let onConfirm: () => void; - let isConfirmButtonDisabled = false; - const handleConfirm = () => { - isConfirmButtonDisabled = true; onConfirm(); }; @@ -37,7 +34,7 @@ {cancelText} {/if} - From 9000ce4283e42c38f9e77513fcd2fbd69cab2f68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 13:11:55 -0700 Subject: [PATCH 15/15] chore(deps): bump docker/build-push-action from 5.4.0 to 6.0.0 (#10433) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5.4.0 to 6.0.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v5.4.0...v6.0.0) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 512f716f98..150bc48af8 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v5.4.0 + uses: docker/build-push-action@v6.0.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b439d50358..3d380a5071 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -115,7 +115,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v5.4.0 + uses: docker/build-push-action@v6.0.0 with: context: ${{ matrix.context }} file: ${{ matrix.file }}