mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +00:00
Merge branch 'main' of github.com:immich-app/immich into feat/mobile/backup-with-album-info
This commit is contained in:
commit
295943e0b5
52 changed files with 915 additions and 869 deletions
2
.github/workflows/cli.yml
vendored
2
.github/workflows/cli.yml
vendored
|
@ -88,7 +88,7 @@ jobs:
|
||||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v5.4.0
|
uses: docker/build-push-action@v6.0.0
|
||||||
with:
|
with:
|
||||||
file: cli/Dockerfile
|
file: cli/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
|
@ -115,7 +115,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v5.4.0
|
uses: docker/build-push-action@v6.0.0
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
file: ${{ matrix.file }}
|
file: ${{ matrix.file }}
|
||||||
|
|
|
@ -27,7 +27,7 @@ For more information about setting up the community image see [here](https://git
|
||||||
|
|
||||||
:::info
|
:::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/)
|
- 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
|
- 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)_
|
- 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**"
|
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.
|
||||||
|
|
||||||
<details >
|
<details >
|
||||||
<summary>Using an existing Postgres container? Click me! Otherwise proceed to step 5.</summary>
|
<summary>Using an existing Postgres container? Click me! Otherwise proceed to step 5.</summary>
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -70,6 +71,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
5. Click "**Save Changes**", you will be promoted to edit stack UI labels, just leave this blank and click "**Ok**"
|
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**"
|
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:
|
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:
|
||||||
|
|
|
@ -398,14 +398,7 @@ export const utils = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vector = Array.from({ length: 512 }, Math.random);
|
await client.query('INSERT INTO asset_faces ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
|
||||||
const embedding = `[${vector.join(',')}]`;
|
|
||||||
|
|
||||||
await client.query('INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', [
|
|
||||||
assetId,
|
|
||||||
personId,
|
|
||||||
embedding,
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setPersonThumbnail: async (personId: string) => {
|
setPersonThumbnail: async (personId: string) => {
|
||||||
|
|
|
@ -295,6 +295,8 @@
|
||||||
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
|
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
|
||||||
"memories_start_over": "Start Over",
|
"memories_start_over": "Start Over",
|
||||||
"memories_swipe_to_close": "Swipe up to close",
|
"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",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"motion_photos_page_title": "Motion Photos",
|
"motion_photos_page_title": "Motion Photos",
|
||||||
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
|
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
|
||||||
|
|
|
@ -295,6 +295,8 @@
|
||||||
"memories_check_back_tomorrow": "明日もう一度確認してください",
|
"memories_check_back_tomorrow": "明日もう一度確認してください",
|
||||||
"memories_start_over": "始める",
|
"memories_start_over": "始める",
|
||||||
"memories_swipe_to_close": "上にスワイプして閉じる",
|
"memories_swipe_to_close": "上にスワイプして閉じる",
|
||||||
|
"memories_year_ago": "過去1年間",
|
||||||
|
"memories_years_ago": "過去{}年間",
|
||||||
"monthly_title_text_date_format": "yyyy年 MM月",
|
"monthly_title_text_date_format": "yyyy年 MM月",
|
||||||
"motion_photos_page_title": "モーションフォト",
|
"motion_photos_page_title": "モーションフォト",
|
||||||
"multiselect_grid_edit_date_time_err_read_only": "読み取り専用の項目の日付を変更できません",
|
"multiselect_grid_edit_date_time_err_read_only": "読み取り専用の項目の日付を変更できません",
|
||||||
|
|
|
@ -295,6 +295,8 @@
|
||||||
"memories_check_back_tomorrow": "Kom morgen terug voor meer herinneringen",
|
"memories_check_back_tomorrow": "Kom morgen terug voor meer herinneringen",
|
||||||
"memories_start_over": "Opnieuw beginnen",
|
"memories_start_over": "Opnieuw beginnen",
|
||||||
"memories_swipe_to_close": "Swipe omhoog om te sluiten",
|
"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",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"motion_photos_page_title": "Bewegende foto's",
|
"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",
|
"multiselect_grid_edit_date_time_err_read_only": "Kan datum van alleen-lezen asset(s) niet wijzigen, overslaan",
|
||||||
|
|
|
@ -295,6 +295,8 @@
|
||||||
"memories_check_back_tomorrow": "明天再看",
|
"memories_check_back_tomorrow": "明天再看",
|
||||||
"memories_start_over": "再看一次",
|
"memories_start_over": "再看一次",
|
||||||
"memories_swipe_to_close": "上划关闭",
|
"memories_swipe_to_close": "上划关闭",
|
||||||
|
"memories_year_ago": "1年前",
|
||||||
|
"memories_years_ago": "{}年前",
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"motion_photos_page_title": "动图",
|
"motion_photos_page_title": "动图",
|
||||||
"multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过",
|
"multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过",
|
||||||
|
|
|
@ -295,6 +295,8 @@
|
||||||
"memories_check_back_tomorrow": "明天再看",
|
"memories_check_back_tomorrow": "明天再看",
|
||||||
"memories_start_over": "再看一次",
|
"memories_start_over": "再看一次",
|
||||||
"memories_swipe_to_close": "上划关闭",
|
"memories_swipe_to_close": "上划关闭",
|
||||||
|
"memories_year_ago": "1年前",
|
||||||
|
"memories_years_ago": "{}年前",
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"motion_photos_page_title": "动图",
|
"motion_photos_page_title": "动图",
|
||||||
"multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过",
|
"multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过",
|
||||||
|
|
|
@ -295,6 +295,8 @@
|
||||||
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
|
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
|
||||||
"memories_start_over": "Start Over",
|
"memories_start_over": "Start Over",
|
||||||
"memories_swipe_to_close": "Swipe up to close",
|
"memories_swipe_to_close": "Swipe up to close",
|
||||||
|
"memories_year_ago": "1年前",
|
||||||
|
"memories_years_ago": "{}年前",
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"motion_photos_page_title": "Motion Photos",
|
"motion_photos_page_title": "Motion Photos",
|
||||||
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
|
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
|
||||||
|
|
|
@ -298,7 +298,7 @@ class MapPage extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 30,
|
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: onZoomToLocation,
|
onPressed: onZoomToLocation,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
|
|
|
@ -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_people_row.dart';
|
||||||
import 'package:immich_mobile/widgets/search/curated_places_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/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/routing/router.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
@ -31,7 +31,7 @@ class SearchPage extends HookConsumerWidget {
|
||||||
final curatedPeople = ref.watch(getAllPeopleProvider);
|
final curatedPeople = ref.watch(getAllPeopleProvider);
|
||||||
final isMapEnabled =
|
final isMapEnabled =
|
||||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map));
|
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(
|
TextStyle categoryTitleStyle = const TextStyle(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
@ -53,16 +53,15 @@ class SearchPage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
buildPeople() {
|
buildPeople() {
|
||||||
return SizedBox(
|
return curatedPeople.widgetWhen(
|
||||||
height: imageSize,
|
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
|
||||||
child: curatedPeople.widgetWhen(
|
onData: (people) {
|
||||||
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
|
return SearchRowSection(
|
||||||
onData: (people) => Padding(
|
onViewAllPressed: () => context.pushRoute(const AllPeopleRoute()),
|
||||||
padding: const EdgeInsets.only(
|
title: "search_page_people".tr(),
|
||||||
left: 16,
|
isEmpty: people.isEmpty,
|
||||||
top: 8,
|
|
||||||
),
|
|
||||||
child: CuratedPeopleRow(
|
child: CuratedPeopleRow(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
content: people
|
content: people
|
||||||
.map((e) => SearchCuratedContent(label: e.name, id: e.id))
|
.map((e) => SearchCuratedContent(label: e.name, id: e.id))
|
||||||
.take(12)
|
.take(12)
|
||||||
|
@ -79,42 +78,46 @@ class SearchPage extends HookConsumerWidget {
|
||||||
showNameEditModel(person.id, person.label),
|
showNameEditModel(person.id, person.label),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildPlaces() {
|
buildPlaces() {
|
||||||
return SizedBox(
|
return places.widgetWhen(
|
||||||
height: imageSize,
|
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
|
||||||
child: places.widgetWhen(
|
onData: (data) {
|
||||||
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
|
return SearchRowSection(
|
||||||
onData: (data) => CuratedPlacesRow(
|
onViewAllPressed: () => context.pushRoute(const AllPlacesRoute()),
|
||||||
isMapEnabled: isMapEnabled,
|
title: "search_page_places".tr(),
|
||||||
content: data,
|
isEmpty: !isMapEnabled && data.isEmpty,
|
||||||
imageSize: imageSize,
|
child: CuratedPlacesRow(
|
||||||
onTap: (content, index) {
|
isMapEnabled: isMapEnabled,
|
||||||
context.pushRoute(
|
content: data,
|
||||||
SearchInputRoute(
|
imageSize: imageSize,
|
||||||
prefilter: SearchFilter(
|
onTap: (content, index) {
|
||||||
people: {},
|
context.pushRoute(
|
||||||
location: SearchLocationFilter(
|
SearchInputRoute(
|
||||||
city: content.label,
|
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(
|
return Scaffold(
|
||||||
appBar: const ImmichAppBar(),
|
appBar: const ImmichAppBar(),
|
||||||
body: Stack(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
ListView(
|
buildSearchButton(),
|
||||||
children: [
|
const SizedBox(height: 8.0),
|
||||||
buildSearchButton(),
|
buildPeople(),
|
||||||
SearchRowTitle(
|
const SizedBox(height: 8.0),
|
||||||
title: "search_page_people".tr(),
|
buildPlaces(),
|
||||||
onViewAllPressed: () =>
|
const SizedBox(height: 24.0),
|
||||||
context.pushRoute(const AllPeopleRoute()),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'search_page_your_activity',
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
buildPeople(),
|
).tr(),
|
||||||
SearchRowTitle(
|
),
|
||||||
title: "search_page_places".tr(),
|
ListTile(
|
||||||
onViewAllPressed: () =>
|
leading: Icon(
|
||||||
context.pushRoute(const AllPlacesRoute()),
|
Icons.favorite_border_rounded,
|
||||||
top: 0,
|
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),
|
).tr(),
|
||||||
buildPlaces(),
|
),
|
||||||
const SizedBox(height: 24.0),
|
ListTile(
|
||||||
Padding(
|
title: Text('search_page_videos', style: categoryTitleStyle).tr(),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
leading: Icon(
|
||||||
child: Text(
|
Icons.play_circle_outline,
|
||||||
'search_page_your_activity',
|
color: categoryIconColor,
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
),
|
||||||
fontWeight: FontWeight.w500,
|
onTap: () => context.pushRoute(const AllVideosRoute()),
|
||||||
),
|
),
|
||||||
).tr(),
|
const CategoryDivider(),
|
||||||
),
|
ListTile(
|
||||||
ListTile(
|
title: Text(
|
||||||
leading: Icon(
|
'search_page_motion_photos',
|
||||||
Icons.favorite_border_rounded,
|
style: categoryTitleStyle,
|
||||||
color: categoryIconColor,
|
).tr(),
|
||||||
),
|
leading: Icon(
|
||||||
title: Text('search_page_favorites', style: categoryTitleStyle)
|
Icons.motion_photos_on_outlined,
|
||||||
.tr(),
|
color: categoryIconColor,
|
||||||
onTap: () => context.pushRoute(const FavoritesRoute()),
|
),
|
||||||
),
|
onTap: () => context.pushRoute(const AllMotionPhotosRoute()),
|
||||||
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()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
@ -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<String> {
|
|
||||||
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<void> 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<AssetDescriptionNotifier, String, Asset>(
|
|
||||||
(ref, asset) => AssetDescriptionNotifier(
|
|
||||||
ref.watch(dbProvider),
|
|
||||||
ref.watch(assetDescriptionServiceProvider),
|
|
||||||
asset,
|
|
||||||
),
|
|
||||||
);
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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/entities/exif_info.entity.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
@ -12,46 +13,36 @@ class AssetDescriptionService {
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
final ApiService _api;
|
final ApiService _api;
|
||||||
|
|
||||||
setDescription(
|
Future<void> setDescription(
|
||||||
String description,
|
Asset asset,
|
||||||
String remoteAssetId,
|
String newDescription,
|
||||||
int localExifId,
|
|
||||||
) async {
|
) 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(
|
final result = await _api.assetsApi.updateAsset(
|
||||||
remoteAssetId,
|
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);
|
var exifInfo = await _db.exifInfos.get(localExifId);
|
||||||
|
|
||||||
if (exifInfo != null) {
|
if (exifInfo != null) {
|
||||||
exifInfo.description = result!.exifInfo!.description;
|
exifInfo.description = description;
|
||||||
await _db.writeTxn(
|
await _db.writeTxn(
|
||||||
() => _db.exifInfos.put(exifInfo),
|
() => _db.exifInfos.put(exifInfo),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> 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(
|
final assetDescriptionServiceProvider = Provider(
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/models/memories/memory.model.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:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
import '../utils/string_helper.dart';
|
|
||||||
|
|
||||||
final memoryServiceProvider = StateProvider<MemoryService>((ref) {
|
final memoryServiceProvider = StateProvider<MemoryService>((ref) {
|
||||||
return MemoryService(
|
return MemoryService(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
|
@ -42,9 +41,12 @@ class MemoryService {
|
||||||
final dbAssets =
|
final dbAssets =
|
||||||
await _db.assets.getAllByRemoteId(assets.map((e) => e.id));
|
await _db.assets.getAllByRemoteId(assets.map((e) => e.id));
|
||||||
if (dbAssets.isNotEmpty) {
|
if (dbAssets.isNotEmpty) {
|
||||||
|
final String title = yearsAgo <= 1
|
||||||
|
? 'memories_year_ago'.tr()
|
||||||
|
: 'memories_years_ago'.tr(args: [yearsAgo.toString()]);
|
||||||
memories.add(
|
memories.add(
|
||||||
Memory(
|
Memory(
|
||||||
title: '$yearsAgo year${s(yearsAgo)} ago',
|
title: title,
|
||||||
assets: dbAssets,
|
assets: dbAssets,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,10 +2,11 @@ import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/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/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.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:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
@ -13,9 +14,11 @@ class DescriptionInput extends HookConsumerWidget {
|
||||||
DescriptionInput({
|
DescriptionInput({
|
||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
|
this.exifInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Asset asset;
|
final Asset asset;
|
||||||
|
final ExifInfo? exifInfo;
|
||||||
final Logger _log = Logger('DescriptionInput');
|
final Logger _log = Logger('DescriptionInput');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -25,25 +28,25 @@ class DescriptionInput extends HookConsumerWidget {
|
||||||
final focusNode = useFocusNode();
|
final focusNode = useFocusNode();
|
||||||
final isFocus = useState(false);
|
final isFocus = useState(false);
|
||||||
final isTextEmpty = useState(controller.text.isEmpty);
|
final isTextEmpty = useState(controller.text.isEmpty);
|
||||||
final descriptionProvider =
|
final descriptionProvider = ref.watch(assetDescriptionServiceProvider);
|
||||||
ref.watch(assetDescriptionProvider(asset).notifier);
|
|
||||||
final description = ref.watch(assetDescriptionProvider(asset));
|
|
||||||
final owner = ref.watch(currentUserProvider);
|
final owner = ref.watch(currentUserProvider);
|
||||||
final hasError = useState(false);
|
final hasError = useState(false);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
controller.text = description;
|
controller.text = exifInfo?.description ?? '';
|
||||||
isTextEmpty.value = description.isEmpty;
|
isTextEmpty.value = exifInfo?.description?.isEmpty ?? true;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[description],
|
[exifInfo?.description],
|
||||||
);
|
);
|
||||||
|
|
||||||
submitDescription(String description) async {
|
submitDescription(String description) async {
|
||||||
hasError.value = false;
|
hasError.value = false;
|
||||||
try {
|
try {
|
||||||
await descriptionProvider.setDescription(
|
await descriptionProvider.setDescription(
|
||||||
|
asset,
|
||||||
description,
|
description,
|
||||||
);
|
);
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
|
@ -85,7 +88,7 @@ class DescriptionInput extends HookConsumerWidget {
|
||||||
isFocus.value = false;
|
isFocus.value = false;
|
||||||
focusNode.unfocus();
|
focusNode.unfocus();
|
||||||
|
|
||||||
if (description != controller.text) {
|
if (exifInfo?.description != controller.text) {
|
||||||
await submitDescription(controller.text);
|
await submitDescription(controller.text);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -73,7 +73,8 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
dateWidget,
|
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
dateWidget,
|
dateWidget,
|
||||||
if (asset.isRemote) DescriptionInput(asset: asset),
|
if (asset.isRemote)
|
||||||
|
DescriptionInput(asset: asset, exifInfo: exifInfo),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16.0),
|
padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16.0),
|
||||||
child: ExifLocation(
|
child: ExifLocation(
|
||||||
|
|
|
@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.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/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/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
|
|
||||||
class CuratedPeopleRow extends StatelessWidget {
|
class CuratedPeopleRow extends StatelessWidget {
|
||||||
|
static const double imageSize = 60.0;
|
||||||
|
|
||||||
final List<SearchCuratedContent> content;
|
final List<SearchCuratedContent> content;
|
||||||
final EdgeInsets? padding;
|
final EdgeInsets? padding;
|
||||||
|
|
||||||
|
@ -24,88 +25,68 @@ class CuratedPeopleRow extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const imageSize = 60.0;
|
return SizedBox(
|
||||||
|
height: imageSize + 30,
|
||||||
// Guard empty [content]
|
child: ListView.separated(
|
||||||
if (content.isEmpty) {
|
padding: padding,
|
||||||
// Return empty thumbnail
|
scrollDirection: Axis.horizontal,
|
||||||
return Align(
|
separatorBuilder: (context, index) => const SizedBox(width: 16),
|
||||||
alignment: Alignment.centerLeft,
|
itemBuilder: (context, index) {
|
||||||
child: Padding(
|
final person = content[index];
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
final headers = {
|
||||||
child: SizedBox(
|
"x-immich-user-token": Store.get(StoreKey.accessToken),
|
||||||
width: imageSize,
|
};
|
||||||
height: imageSize,
|
return Column(
|
||||||
child: ThumbnailWithInfo(
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
textInfo: '',
|
children: [
|
||||||
onTap: () {},
|
GestureDetector(
|
||||||
),
|
onTap: () => onTap?.call(person, index),
|
||||||
),
|
child: SizedBox(
|
||||||
),
|
height: imageSize,
|
||||||
);
|
child: Material(
|
||||||
}
|
shape: const CircleBorder(side: BorderSide.none),
|
||||||
|
elevation: 3,
|
||||||
return ListView.builder(
|
child: CircleAvatar(
|
||||||
padding: padding,
|
maxRadius: imageSize / 2,
|
||||||
scrollDirection: Axis.horizontal,
|
backgroundImage: NetworkImage(
|
||||||
itemBuilder: (context, index) {
|
getFaceThumbnailUrl(person.id),
|
||||||
final person = content[index];
|
headers: headers,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (person.label == "")
|
),
|
||||||
GestureDetector(
|
const SizedBox(height: 8),
|
||||||
onTap: () => onNameTap?.call(person, index),
|
_buildPersonLabel(context, person, index),
|
||||||
child: Padding(
|
],
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
);
|
||||||
child: Text(
|
},
|
||||||
"exif_bottom_sheet_person_add_person",
|
itemCount: content.length,
|
||||||
style: context.textTheme.labelLarge?.copyWith(
|
),
|
||||||
color: context.primaryColor,
|
);
|
||||||
),
|
}
|
||||||
).tr(),
|
|
||||||
),
|
Widget _buildPersonLabel(
|
||||||
)
|
BuildContext context,
|
||||||
else
|
SearchCuratedContent person,
|
||||||
Padding(
|
int index,
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
) {
|
||||||
child: Text(
|
if (person.label.isEmpty) {
|
||||||
person.label,
|
return GestureDetector(
|
||||||
textAlign: TextAlign.center,
|
onTap: () => onNameTap?.call(person, index),
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
style: context.textTheme.labelLarge,
|
"exif_bottom_sheet_person_add_person",
|
||||||
),
|
style: context.textTheme.labelLarge?.copyWith(
|
||||||
),
|
color: context.primaryColor,
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
).tr(),
|
||||||
},
|
);
|
||||||
itemCount: content.length,
|
}
|
||||||
|
return Text(
|
||||||
|
person.label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:flutter/material.dart';
|
||||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
|
||||||
import 'package:immich_mobile/widgets/search/curated_row.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/widgets/search/thumbnail_with_info.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/entities/store.entity.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({
|
const CuratedPlacesRow({
|
||||||
super.key,
|
super.key,
|
||||||
required super.content,
|
required this.content,
|
||||||
|
required this.imageSize,
|
||||||
this.isMapEnabled = true,
|
this.isMapEnabled = true,
|
||||||
super.imageSize,
|
this.onTap,
|
||||||
super.onTap,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final bool isMapEnabled;
|
||||||
|
final List<SearchCuratedContent> content;
|
||||||
|
final double imageSize;
|
||||||
|
|
||||||
|
/// Callback with the content and the index when tapped
|
||||||
|
final Function(SearchCuratedContent, int)? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Calculating the actual index of the content based on the whether map is enabled or not.
|
// 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
|
// 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;
|
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
|
return SizedBox(
|
||||||
if (!isMapEnabled && content.isEmpty) {
|
height: imageSize,
|
||||||
return Align(
|
child: ListView.builder(
|
||||||
alignment: Alignment.centerLeft,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Padding(
|
padding: const EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
horizontal: 16,
|
||||||
child: SizedBox(
|
),
|
||||||
|
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,
|
width: imageSize,
|
||||||
height: imageSize,
|
height: imageSize,
|
||||||
child: ThumbnailWithInfo(
|
child: Padding(
|
||||||
textInfo: '',
|
padding: const EdgeInsets.only(right: 10.0),
|
||||||
onTap: () {},
|
child: ThumbnailWithInfo(
|
||||||
|
imageUrl: thumbnailRequestUrl,
|
||||||
|
textInfo: object.label,
|
||||||
|
onTap: () => onTap?.call(object, actualIndex),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
itemCount: content.length + actualContentIndex,
|
||||||
}
|
|
||||||
|
|
||||||
return 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 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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<SearchCuratedContent> 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/models/search/search_filter.model.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/providers/search/search_filter.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/common/dropdown.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class CameraPicker extends HookConsumerWidget {
|
class CameraPicker extends HookConsumerWidget {
|
||||||
|
@ -12,6 +13,7 @@ class CameraPicker extends HookConsumerWidget {
|
||||||
|
|
||||||
final Function(Map<String, String?>) onSelect;
|
final Function(Map<String, String?>) onSelect;
|
||||||
final SearchCameraFilter? filter;
|
final SearchCameraFilter? filter;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final makeTextController = useTextEditingController(text: filter?.make);
|
final makeTextController = useTextEditingController(text: filter?.make);
|
||||||
|
@ -32,90 +34,73 @@ class CameraPicker extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final inputDecorationTheme = InputDecorationTheme(
|
final makeWidget = SearchDropdown(
|
||||||
border: OutlineInputBorder(
|
dropdownMenuEntries: switch (make) {
|
||||||
borderRadius: BorderRadius.circular(20),
|
AsyncError() => [],
|
||||||
),
|
AsyncData(:final value) => value
|
||||||
contentPadding: const EdgeInsets.only(left: 16),
|
.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(
|
final modelWidget = SearchDropdown(
|
||||||
shape: WidgetStatePropertyAll<OutlinedBorder>(
|
dropdownMenuEntries: switch (models) {
|
||||||
RoundedRectangleBorder(
|
AsyncError() => [],
|
||||||
borderRadius: BorderRadius.circular(15),
|
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(
|
if (context.isMobile) {
|
||||||
padding: const EdgeInsets.only(
|
return Column(
|
||||||
// bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
children: [
|
||||||
DropdownMenu(
|
makeWidget,
|
||||||
dropdownMenuEntries: switch (make) {
|
const SizedBox(height: 8),
|
||||||
AsyncError() => [],
|
modelWidget,
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
Expanded(child: makeWidget),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: modelWidget),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
52
mobile/lib/widgets/search/search_filter/common/dropdown.dart
Normal file
52
mobile/lib/widgets/search/search_filter/common/dropdown.dart
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SearchDropdown<T> extends StatelessWidget {
|
||||||
|
const SearchDropdown({
|
||||||
|
super.key,
|
||||||
|
required this.dropdownMenuEntries,
|
||||||
|
required this.controller,
|
||||||
|
this.onSelected,
|
||||||
|
this.label,
|
||||||
|
this.leadingIcon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<DropdownMenuEntry<T>> 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<OutlinedBorder>(
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,7 +38,10 @@ class FilterBottomSheetScaffold extends StatelessWidget {
|
||||||
style: context.textTheme.headlineSmall,
|
style: context.textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
buildChildWidget(),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: buildChildWidget(),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
|
@ -2,9 +2,9 @@ import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/models/search/search_filter.model.dart';
|
||||||
import 'package:immich_mobile/providers/search/search_filter.provider.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';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class LocationPicker extends HookConsumerWidget {
|
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<OutlinedBorder>(
|
|
||||||
RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(15),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
DropdownMenu(
|
SearchDropdown(
|
||||||
dropdownMenuEntries: switch (countries) {
|
dropdownMenuEntries: switch (countries) {
|
||||||
AsyncError() => [],
|
AsyncError() => [],
|
||||||
AsyncData(:final value) => value
|
AsyncData(:final value) => value
|
||||||
|
@ -78,14 +63,8 @@ class LocationPicker extends HookConsumerWidget {
|
||||||
.toList(),
|
.toList(),
|
||||||
_ => [],
|
_ => [],
|
||||||
},
|
},
|
||||||
menuHeight: 400,
|
|
||||||
width: context.width * 0.9,
|
|
||||||
label: const Text('search_filter_location_country').tr(),
|
label: const Text('search_filter_location_country').tr(),
|
||||||
inputDecorationTheme: inputDecorationTheme,
|
|
||||||
menuStyle: menuStyle,
|
|
||||||
controller: countryTextController,
|
controller: countryTextController,
|
||||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
|
||||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
if (value.toString() == selectedCountry.value) {
|
if (value.toString() == selectedCountry.value) {
|
||||||
return;
|
return;
|
||||||
|
@ -103,7 +82,7 @@ class LocationPicker extends HookConsumerWidget {
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 16,
|
height: 16,
|
||||||
),
|
),
|
||||||
DropdownMenu(
|
SearchDropdown(
|
||||||
dropdownMenuEntries: switch (states) {
|
dropdownMenuEntries: switch (states) {
|
||||||
AsyncError() => [],
|
AsyncError() => [],
|
||||||
AsyncData(:final value) => value
|
AsyncData(:final value) => value
|
||||||
|
@ -116,14 +95,8 @@ class LocationPicker extends HookConsumerWidget {
|
||||||
.toList(),
|
.toList(),
|
||||||
_ => [],
|
_ => [],
|
||||||
},
|
},
|
||||||
menuHeight: 400,
|
|
||||||
width: context.width * 0.9,
|
|
||||||
label: const Text('search_filter_location_state').tr(),
|
label: const Text('search_filter_location_state').tr(),
|
||||||
inputDecorationTheme: inputDecorationTheme,
|
|
||||||
menuStyle: menuStyle,
|
|
||||||
controller: stateTextController,
|
controller: stateTextController,
|
||||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
|
||||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
if (value.toString() == selectedState.value) {
|
if (value.toString() == selectedState.value) {
|
||||||
return;
|
return;
|
||||||
|
@ -140,7 +113,7 @@ class LocationPicker extends HookConsumerWidget {
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 16,
|
height: 16,
|
||||||
),
|
),
|
||||||
DropdownMenu(
|
SearchDropdown(
|
||||||
dropdownMenuEntries: switch (cities) {
|
dropdownMenuEntries: switch (cities) {
|
||||||
AsyncError() => [],
|
AsyncError() => [],
|
||||||
AsyncData(:final value) => value
|
AsyncData(:final value) => value
|
||||||
|
@ -153,14 +126,8 @@ class LocationPicker extends HookConsumerWidget {
|
||||||
.toList(),
|
.toList(),
|
||||||
_ => [],
|
_ => [],
|
||||||
},
|
},
|
||||||
menuHeight: 400,
|
|
||||||
width: context.width * 0.9,
|
|
||||||
label: const Text('search_filter_location_city').tr(),
|
label: const Text('search_filter_location_city').tr(),
|
||||||
inputDecorationTheme: inputDecorationTheme,
|
|
||||||
menuStyle: menuStyle,
|
|
||||||
controller: cityTextController,
|
controller: cityTextController,
|
||||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
|
||||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
selectedCity.value = value.toString();
|
selectedCity.value = value.toString();
|
||||||
onSelected({
|
onSelected({
|
||||||
|
|
76
mobile/lib/widgets/search/search_map_thumbnail.dart
Normal file
76
mobile/lib/widgets/search/search_map_thumbnail.dart
Normal file
|
@ -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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
37
mobile/lib/widgets/search/search_row_section.dart
Normal file
37
mobile/lib/widgets/search/search_row_section.dart
Normal file
|
@ -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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,45 +3,36 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|
||||||
class SearchRowTitle extends StatelessWidget {
|
class SearchRowTitle extends StatelessWidget {
|
||||||
final Function() onViewAllPressed;
|
|
||||||
final String title;
|
|
||||||
final double top;
|
|
||||||
|
|
||||||
const SearchRowTitle({
|
const SearchRowTitle({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onViewAllPressed,
|
required this.onViewAllPressed,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.top = 12,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final Function() onViewAllPressed;
|
||||||
|
final String title;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Row(
|
||||||
padding: EdgeInsets.only(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
left: 16.0,
|
children: [
|
||||||
right: 16.0,
|
Text(
|
||||||
top: top,
|
title,
|
||||||
),
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
child: Row(
|
fontWeight: FontWeight.w500,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
),
|
||||||
children: [
|
),
|
||||||
Text(
|
TextButton(
|
||||||
title,
|
onPressed: onViewAllPressed,
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
child: Text(
|
||||||
fontWeight: FontWeight.w500,
|
'search_page_view_all_button',
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(
|
||||||
|
color: context.primaryColor,
|
||||||
),
|
),
|
||||||
),
|
).tr(),
|
||||||
TextButton(
|
),
|
||||||
onPressed: onViewAllPressed,
|
],
|
||||||
child: Text(
|
|
||||||
'search_page_view_all_button',
|
|
||||||
style: context.textTheme.labelLarge?.copyWith(
|
|
||||||
color: context.primaryColor,
|
|
||||||
),
|
|
||||||
).tr(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
15
server/package-lock.json
generated
15
server/package-lock.json
generated
|
@ -34,7 +34,7 @@
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"exiftool-vendored": "~26.2.0",
|
"exiftool-vendored": "~27.0.0",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"geo-tz": "^8.0.0",
|
"geo-tz": "^8.0.0",
|
||||||
|
@ -9127,9 +9127,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/exiftool-vendored": {
|
"node_modules/exiftool-vendored": {
|
||||||
"version": "26.2.0",
|
"version": "27.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-27.0.0.tgz",
|
||||||
"integrity": "sha512-7P6jQ944or7ic2SJzW+uaWK4TLDXlaCppHrBayl4MpIrVcEeQjiQTez4/oOH0wULIRu4j4H6Xruz4SLrDaafUg==",
|
"integrity": "sha512-/jHX8Jjadj0YJzpqnuBo1Yy2ln2hnRbBIc+3jcVOLQ6qhHEKsLRlfJ145Ghn7k/EcnfpDzVX3V8AUCTC8juTow==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@photostructure/tz-lookup": "^10.0.0",
|
"@photostructure/tz-lookup": "^10.0.0",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
|
@ -22600,9 +22601,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exiftool-vendored": {
|
"exiftool-vendored": {
|
||||||
"version": "26.2.0",
|
"version": "27.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-27.0.0.tgz",
|
||||||
"integrity": "sha512-7P6jQ944or7ic2SJzW+uaWK4TLDXlaCppHrBayl4MpIrVcEeQjiQTez4/oOH0wULIRu4j4H6Xruz4SLrDaafUg==",
|
"integrity": "sha512-/jHX8Jjadj0YJzpqnuBo1Yy2ln2hnRbBIc+3jcVOLQ6qhHEKsLRlfJ145Ghn7k/EcnfpDzVX3V8AUCTC8juTow==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@photostructure/tz-lookup": "^10.0.0",
|
"@photostructure/tz-lookup": "^10.0.0",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
|
|
|
@ -60,7 +60,7 @@
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"exiftool-vendored": "~26.2.0",
|
"exiftool-vendored": "~27.0.0",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"geo-tz": "^8.0.0",
|
"geo-tz": "^8.0.0",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
|
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.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 })
|
@Entity('asset_faces', { synchronize: false })
|
||||||
@Index('IDX_asset_faces_assetId_personId', ['assetId', 'personId'])
|
@Index('IDX_asset_faces_assetId_personId', ['assetId', 'personId'])
|
||||||
|
@ -15,9 +16,8 @@ export class AssetFaceEntity {
|
||||||
@Column({ nullable: true, type: 'uuid' })
|
@Column({ nullable: true, type: 'uuid' })
|
||||||
personId!: string | null;
|
personId!: string | null;
|
||||||
|
|
||||||
@Index('face_index', { synchronize: false })
|
@OneToOne(() => FaceSearchEntity, (faceSearchEntity) => faceSearchEntity.face, { cascade: ['insert'] })
|
||||||
@Column({ type: 'float4', array: true, select: false, transformer: { from: (v) => JSON.parse(v), to: (v) => v } })
|
faceSearch?: FaceSearchEntity;
|
||||||
embedding!: number[];
|
|
||||||
|
|
||||||
@Column({ default: 0, type: 'int' })
|
@Column({ default: 0, type: 'int' })
|
||||||
imageWidth!: number;
|
imageWidth!: number;
|
||||||
|
|
21
server/src/entities/face-search.entity.ts
Normal file
21
server/src/entities/face-search.entity.ts
Normal file
|
@ -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[];
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import { AssetStackEntity } from 'src/entities/asset-stack.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { AuditEntity } from 'src/entities/audit.entity';
|
import { AuditEntity } from 'src/entities/audit.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.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 { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
|
||||||
import { LibraryEntity } from 'src/entities/library.entity';
|
import { LibraryEntity } from 'src/entities/library.entity';
|
||||||
import { MemoryEntity } from 'src/entities/memory.entity';
|
import { MemoryEntity } from 'src/entities/memory.entity';
|
||||||
|
@ -34,6 +35,7 @@ export const entities = [
|
||||||
AssetJobStatusEntity,
|
AssetJobStatusEntity,
|
||||||
AuditEntity,
|
AuditEntity,
|
||||||
ExifEntity,
|
ExifEntity,
|
||||||
|
FaceSearchEntity,
|
||||||
GeodataPlacesEntity,
|
GeodataPlacesEntity,
|
||||||
MemoryEntity,
|
MemoryEntity,
|
||||||
MoveEntity,
|
MoveEntity,
|
||||||
|
|
|
@ -47,6 +47,10 @@ export interface ImageDimensions {
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InputDimensions extends ImageDimensions {
|
||||||
|
inputPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
format: VideoFormat;
|
format: VideoFormat;
|
||||||
videoStreams: VideoStreamInfo[];
|
videoStreams: VideoStreamInfo[];
|
||||||
|
|
54
server/src/migrations/1718486162779-AddFaceSearchRelation.ts
Normal file
54
server/src/migrations/1718486162779-AddFaceSearchRelation.ts
Normal file
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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)`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -241,15 +241,16 @@ WITH
|
||||||
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
||||||
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
||||||
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
||||||
"faces"."embedding" <= > $1 AS "distance"
|
"search"."embedding" <= > $1 AS "distance"
|
||||||
FROM
|
FROM
|
||||||
"asset_faces" "faces"
|
"asset_faces" "faces"
|
||||||
INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId"
|
INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId"
|
||||||
AND ("asset"."deletedAt" IS NULL)
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
|
INNER JOIN "face_search" "search" ON "search"."faceId" = "faces"."id"
|
||||||
WHERE
|
WHERE
|
||||||
"asset"."ownerId" IN ($2)
|
"asset"."ownerId" IN ($2)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
"faces"."embedding" <= > $1 ASC
|
"search"."embedding" <= > $1 ASC
|
||||||
LIMIT
|
LIMIT
|
||||||
100
|
100
|
||||||
)
|
)
|
||||||
|
|
|
@ -98,7 +98,7 @@ export class DatabaseRepository implements IDatabaseRepository {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (getVectorExtension() === DatabaseExtension.VECTORS) {
|
if (getVectorExtension() === DatabaseExtension.VECTORS) {
|
||||||
this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`);
|
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);
|
const dimSize = await this.getDimSize(table);
|
||||||
await this.dataSource.manager.transaction(async (manager) => {
|
await this.dataSource.manager.transaction(async (manager) => {
|
||||||
await this.setSearchPath(manager);
|
await this.setSearchPath(manager);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
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 geotz from 'geo-tz';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
import { ExifEntity } from 'src/entities/exif.entity';
|
||||||
|
@ -20,40 +20,39 @@ export class MetadataRepository implements IMetadataRepository {
|
||||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(MetadataRepository.name);
|
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() {
|
async teardown() {
|
||||||
await exiftool.end();
|
await this.exiftool.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
readTags(path: string): Promise<ImmichTags | null> {
|
readTags(path: string): Promise<ImmichTags | null> {
|
||||||
return exiftool
|
return this.exiftool.read(path).catch((error) => {
|
||||||
.read(path, undefined, {
|
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
|
||||||
...DefaultReadTaskOptions,
|
return null;
|
||||||
|
}) as Promise<ImmichTags | null>;
|
||||||
// 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<ImmichTags | null>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
|
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
|
||||||
return exiftool.extractBinaryTagToBuffer(tagName, path);
|
return this.exiftool.extractBinaryTagToBuffer(tagName, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await exiftool.write(path, tags, ['-overwrite_original']);
|
await this.exiftool.write(path, tags);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,6 @@ import {
|
||||||
PersonStatistics,
|
PersonStatistics,
|
||||||
UpdateFacesData,
|
UpdateFacesData,
|
||||||
} from 'src/interfaces/person.interface';
|
} from 'src/interfaces/person.interface';
|
||||||
import { asVector } from 'src/utils/database';
|
|
||||||
import { Instrumentation } from 'src/utils/instrumentation';
|
import { Instrumentation } from 'src/utils/instrumentation';
|
||||||
import { Paginated, PaginationOptions, paginate } from 'src/utils/pagination';
|
import { Paginated, PaginationOptions, paginate } from 'src/utils/pagination';
|
||||||
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
|
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
|
||||||
|
@ -249,10 +248,8 @@ export class PersonRepository implements IPersonRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFaces(entities: AssetFaceEntity[]): Promise<string[]> {
|
async createFaces(entities: AssetFaceEntity[]): Promise<string[]> {
|
||||||
const res = await this.assetFaceRepository.insert(
|
const res = await this.assetFaceRepository.save(entities);
|
||||||
entities.map((entity) => ({ ...entity, embedding: () => asVector(entity.embedding, true) })),
|
return res.map((row) => row.id);
|
||||||
);
|
|
||||||
return res.identifiers.map((row) => row.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(entity: Partial<PersonEntity>): Promise<PersonEntity> {
|
async update(entity: Partial<PersonEntity>): Promise<PersonEntity> {
|
||||||
|
|
|
@ -218,10 +218,11 @@ export class SearchRepository implements ISearchRepository {
|
||||||
await this.assetRepository.manager.transaction(async (manager) => {
|
await this.assetRepository.manager.transaction(async (manager) => {
|
||||||
const cte = manager
|
const cte = manager
|
||||||
.createQueryBuilder(AssetFaceEntity, 'faces')
|
.createQueryBuilder(AssetFaceEntity, 'faces')
|
||||||
.select('faces.embedding <=> :embedding', 'distance')
|
.select('search.embedding <=> :embedding', 'distance')
|
||||||
.innerJoin('faces.asset', 'asset')
|
.innerJoin('faces.asset', 'asset')
|
||||||
|
.innerJoin('faces.faceSearch', 'search')
|
||||||
.where('asset.ownerId IN (:...userIds )')
|
.where('asset.ownerId IN (:...userIds )')
|
||||||
.orderBy('faces.embedding <=> :embedding')
|
.orderBy('search.embedding <=> :embedding')
|
||||||
.setParameters({ userIds, embedding: asVector(embedding) });
|
.setParameters({ userIds, embedding: asVector(embedding) });
|
||||||
|
|
||||||
cte.limit(numResults);
|
cte.limit(numResults);
|
||||||
|
|
|
@ -107,6 +107,56 @@ 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 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 () => {
|
it('should queue all people with missing thumbnail path', async () => {
|
||||||
assetMock.getWithout.mockResolvedValue({
|
assetMock.getWithout.mockResolvedValue({
|
||||||
items: [assetStub.image],
|
items: [assetStub.image],
|
||||||
|
|
|
@ -70,7 +70,7 @@ export class MediaService {
|
||||||
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
|
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
return force
|
return force
|
||||||
? this.assetRepository.getAll(pagination, { isVisible: true })
|
? this.assetRepository.getAll(pagination, { isVisible: true, withDeleted: true, withArchived: true })
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
|
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -668,15 +668,18 @@ describe(PersonService.name, () => {
|
||||||
machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock);
|
machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock);
|
||||||
searchMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]);
|
searchMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
const faceId = 'face-id';
|
||||||
|
cryptoMock.randomUUID.mockReturnValue(faceId);
|
||||||
const face = {
|
const face = {
|
||||||
|
id: faceId,
|
||||||
assetId: 'asset-id',
|
assetId: 'asset-id',
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 100,
|
boundingBoxX1: 100,
|
||||||
boundingBoxY1: 100,
|
boundingBoxY1: 100,
|
||||||
boundingBoxX2: 200,
|
boundingBoxX2: 200,
|
||||||
boundingBoxY2: 200,
|
boundingBoxY2: 200,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
|
faceSearch: { faceId, embedding: [1, 2, 3, 4] },
|
||||||
};
|
};
|
||||||
|
|
||||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||||
|
@ -917,9 +920,9 @@ describe(PersonService.name, () => {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
crop: {
|
crop: {
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 428,
|
top: 85,
|
||||||
width: 1102,
|
width: 510,
|
||||||
height: 1102,
|
height: 510,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
mapFaces,
|
mapFaces,
|
||||||
mapPerson,
|
mapPerson,
|
||||||
} from 'src/dtos/person.dto';
|
} from 'src/dtos/person.dto';
|
||||||
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||||
import { PersonPathType } from 'src/entities/move.entity';
|
import { PersonPathType } from 'src/entities/move.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
|
@ -41,13 +42,12 @@ import {
|
||||||
} from 'src/interfaces/job.interface';
|
} from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { BoundingBox, IMachineLearningRepository } from 'src/interfaces/machine-learning.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 { IMoveRepository } from 'src/interfaces/move.interface';
|
||||||
import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface';
|
import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface';
|
||||||
import { ISearchRepository } from 'src/interfaces/search.interface';
|
import { ISearchRepository } from 'src/interfaces/search.interface';
|
||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||||
import { Orientation } from 'src/services/metadata.service';
|
|
||||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { isFacialRecognitionEnabled } from 'src/utils/misc';
|
import { isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||||
|
@ -71,7 +71,7 @@ export class PersonService {
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
|
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
|
||||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||||
) {
|
) {
|
||||||
this.access = AccessCore.create(accessRepository);
|
this.access = AccessCore.create(accessRepository);
|
||||||
|
@ -348,16 +348,21 @@ export class PersonService {
|
||||||
|
|
||||||
if (faces.length > 0) {
|
if (faces.length > 0) {
|
||||||
await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } });
|
await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } });
|
||||||
const mappedFaces = faces.map((face) => ({
|
const mappedFaces: Partial<AssetFaceEntity>[] = [];
|
||||||
assetId: asset.id,
|
for (const face of faces) {
|
||||||
embedding: face.embedding,
|
const faceId = this.cryptoRepository.randomUUID();
|
||||||
imageHeight,
|
mappedFaces.push({
|
||||||
imageWidth,
|
id: faceId,
|
||||||
boundingBoxX1: face.boundingBox.x1,
|
assetId: asset.id,
|
||||||
boundingBoxY1: face.boundingBox.y1,
|
imageHeight,
|
||||||
boundingBoxX2: face.boundingBox.x2,
|
imageWidth,
|
||||||
boundingBoxY2: face.boundingBox.y2,
|
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);
|
const faceIds = await this.repository.createFaces(mappedFaces);
|
||||||
await this.jobRepository.queueAll(faceIds.map((id) => ({ name: JobName.FACIAL_RECOGNITION, data: { id } })));
|
await this.jobRepository.queueAll(faceIds.map((id) => ({ name: JobName.FACIAL_RECOGNITION, data: { id } })));
|
||||||
|
@ -410,14 +415,19 @@ export class PersonService {
|
||||||
|
|
||||||
const face = await this.repository.getFaceByIdWithAssets(
|
const face = await this.repository.getFaceByIdWithAssets(
|
||||||
id,
|
id,
|
||||||
{ person: true, asset: true },
|
{ person: true, asset: true, faceSearch: true },
|
||||||
{ id: true, personId: true, embedding: true },
|
{ id: true, personId: true, faceSearch: { embedding: true } },
|
||||||
);
|
);
|
||||||
if (!face || !face.asset) {
|
if (!face || !face.asset) {
|
||||||
this.logger.warn(`Face ${id} not found`);
|
this.logger.warn(`Face ${id} not found`);
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!face.faceSearch?.embedding) {
|
||||||
|
this.logger.warn(`Face ${id} does not have an embedding`);
|
||||||
|
return JobStatus.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
if (face.personId) {
|
if (face.personId) {
|
||||||
this.logger.debug(`Face ${id} already has a person assigned`);
|
this.logger.debug(`Face ${id} already has a person assigned`);
|
||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
|
@ -425,7 +435,7 @@ export class PersonService {
|
||||||
|
|
||||||
const matches = await this.smartInfoRepository.searchFaces({
|
const matches = await this.smartInfoRepository.searchFaces({
|
||||||
userIds: [face.asset.ownerId],
|
userIds: [face.asset.ownerId],
|
||||||
embedding: face.embedding,
|
embedding: face.faceSearch.embedding,
|
||||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||||
numResults: machineLearning.facialRecognition.minFaces,
|
numResults: machineLearning.facialRecognition.minFaces,
|
||||||
});
|
});
|
||||||
|
@ -449,7 +459,7 @@ export class PersonService {
|
||||||
if (!personId) {
|
if (!personId) {
|
||||||
const matchWithPerson = await this.smartInfoRepository.searchFaces({
|
const matchWithPerson = await this.smartInfoRepository.searchFaces({
|
||||||
userIds: [face.asset.ownerId],
|
userIds: [face.asset.ownerId],
|
||||||
embedding: face.embedding,
|
embedding: face.faceSearch.embedding,
|
||||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||||
numResults: 1,
|
numResults: 1,
|
||||||
hasPerson: true,
|
hasPerson: true,
|
||||||
|
@ -520,7 +530,7 @@ export class PersonService {
|
||||||
return JobStatus.FAILED;
|
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);
|
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
|
||||||
this.storageCore.ensureFolders(thumbnailPath);
|
this.storageCore.ensureFolders(thumbnailPath);
|
||||||
|
@ -601,7 +611,7 @@ export class PersonService {
|
||||||
return person;
|
return person;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getInputDimensions(asset: AssetEntity): Promise<ImageDimensions & { inputPath: string }> {
|
private async getInputDimensions(asset: AssetEntity, oldDims: ImageDimensions): Promise<InputDimensions> {
|
||||||
if (!asset.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) {
|
if (!asset.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) {
|
||||||
throw new Error(`Asset ${asset.id} dimensions are unknown`);
|
throw new Error(`Asset ${asset.id} dimensions are unknown`);
|
||||||
}
|
}
|
||||||
|
@ -611,10 +621,11 @@ export class PersonService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.type === AssetType.IMAGE) {
|
if (asset.type === AssetType.IMAGE) {
|
||||||
const { width, height } = this.withOrientation(asset.exifInfo.orientation as Orientation, {
|
let { exifImageWidth: width, exifImageHeight: height } = asset.exifInfo;
|
||||||
width: asset.exifInfo.exifImageWidth,
|
if (oldDims.height > oldDims.width !== height > width) {
|
||||||
height: asset.exifInfo.exifImageHeight,
|
[width, height] = [height, width];
|
||||||
});
|
}
|
||||||
|
|
||||||
return { width, height, inputPath: asset.originalPath };
|
return { width, height, inputPath: asset.originalPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -622,20 +633,6 @@ export class PersonService {
|
||||||
return { width, height, inputPath: asset.previewPath };
|
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 {
|
private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions {
|
||||||
const widthScale = dims.new.width / dims.old.width;
|
const widthScale = dims.new.width / dims.old.width;
|
||||||
const heightScale = dims.new.height / dims.old.height;
|
const heightScale = dims.new.height / dims.old.height;
|
||||||
|
|
80
server/test/fixtures/asset.stub.ts
vendored
80
server/test/fixtures/asset.stub.ts
vendored
|
@ -209,6 +209,86 @@ export const assetStub = {
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
trashed: Object.freeze<AssetEntity>({
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
|
||||||
|
archived: Object.freeze<AssetEntity>({
|
||||||
|
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<AssetEntity>({
|
external: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
|
|
22
server/test/fixtures/face.stub.ts
vendored
22
server/test/fixtures/face.stub.ts
vendored
|
@ -11,13 +11,13 @@ export const faceStub = {
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
personId: personStub.withName.id,
|
personId: personStub.withName.id,
|
||||||
person: personStub.withName,
|
person: personStub.withName,
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
boundingBoxY1: 0,
|
boundingBoxY1: 0,
|
||||||
boundingBoxX2: 1,
|
boundingBoxX2: 1,
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
faceSearch: { faceId: 'assetFaceId1', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId2',
|
id: 'assetFaceId2',
|
||||||
|
@ -25,13 +25,13 @@ export const faceStub = {
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
personId: personStub.primaryPerson.id,
|
personId: personStub.primaryPerson.id,
|
||||||
person: personStub.primaryPerson,
|
person: personStub.primaryPerson,
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
boundingBoxY1: 0,
|
boundingBoxY1: 0,
|
||||||
boundingBoxX2: 1,
|
boundingBoxX2: 1,
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
faceSearch: { faceId: 'assetFaceId2', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId3',
|
id: 'assetFaceId3',
|
||||||
|
@ -39,13 +39,13 @@ export const faceStub = {
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
personId: personStub.mergePerson.id,
|
personId: personStub.mergePerson.id,
|
||||||
person: personStub.mergePerson,
|
person: personStub.mergePerson,
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
boundingBoxY1: 0,
|
boundingBoxY1: 0,
|
||||||
boundingBoxX2: 1,
|
boundingBoxX2: 1,
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
mergeFace2: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
mergeFace2: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId4',
|
id: 'assetFaceId4',
|
||||||
|
@ -53,13 +53,13 @@ export const faceStub = {
|
||||||
asset: assetStub.image1,
|
asset: assetStub.image1,
|
||||||
personId: personStub.mergePerson.id,
|
personId: personStub.mergePerson.id,
|
||||||
person: personStub.mergePerson,
|
person: personStub.mergePerson,
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
boundingBoxY1: 0,
|
boundingBoxY1: 0,
|
||||||
boundingBoxX2: 1,
|
boundingBoxX2: 1,
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
faceSearch: { faceId: 'assetFaceId4', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId5',
|
id: 'assetFaceId5',
|
||||||
|
@ -67,13 +67,13 @@ export const faceStub = {
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
personId: personStub.newThumbnail.id,
|
personId: personStub.newThumbnail.id,
|
||||||
person: personStub.newThumbnail,
|
person: personStub.newThumbnail,
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 5,
|
boundingBoxX1: 5,
|
||||||
boundingBoxY1: 5,
|
boundingBoxY1: 5,
|
||||||
boundingBoxX2: 505,
|
boundingBoxX2: 505,
|
||||||
boundingBoxY2: 505,
|
boundingBoxY2: 505,
|
||||||
imageHeight: 1000,
|
imageHeight: 2880,
|
||||||
imageWidth: 1000,
|
imageWidth: 2160,
|
||||||
|
faceSearch: { faceId: 'assetFaceId5', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId6',
|
id: 'assetFaceId6',
|
||||||
|
@ -81,13 +81,13 @@ export const faceStub = {
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
personId: personStub.newThumbnail.id,
|
personId: personStub.newThumbnail.id,
|
||||||
person: personStub.newThumbnail,
|
person: personStub.newThumbnail,
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 100,
|
boundingBoxX1: 100,
|
||||||
boundingBoxY1: 100,
|
boundingBoxY1: 100,
|
||||||
boundingBoxX2: 200,
|
boundingBoxX2: 200,
|
||||||
boundingBoxY2: 200,
|
boundingBoxY2: 200,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
|
faceSearch: { faceId: 'assetFaceId6', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId7',
|
id: 'assetFaceId7',
|
||||||
|
@ -95,13 +95,13 @@ export const faceStub = {
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
personId: personStub.newThumbnail.id,
|
personId: personStub.newThumbnail.id,
|
||||||
person: personStub.newThumbnail,
|
person: personStub.newThumbnail,
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 300,
|
boundingBoxX1: 300,
|
||||||
boundingBoxY1: 300,
|
boundingBoxY1: 300,
|
||||||
boundingBoxX2: 495,
|
boundingBoxX2: 495,
|
||||||
boundingBoxY2: 495,
|
boundingBoxY2: 495,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 500,
|
imageWidth: 500,
|
||||||
|
faceSearch: { faceId: 'assetFaceId7', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
noPerson1: Object.freeze<AssetFaceEntity>({
|
noPerson1: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId8',
|
id: 'assetFaceId8',
|
||||||
|
@ -109,13 +109,13 @@ export const faceStub = {
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
personId: null,
|
personId: null,
|
||||||
person: null,
|
person: null,
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
boundingBoxY1: 0,
|
boundingBoxY1: 0,
|
||||||
boundingBoxX2: 1,
|
boundingBoxX2: 1,
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
faceSearch: { faceId: 'assetFaceId8', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
noPerson2: Object.freeze<AssetFaceEntity>({
|
noPerson2: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId9',
|
id: 'assetFaceId9',
|
||||||
|
@ -123,12 +123,12 @@ export const faceStub = {
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
personId: null,
|
personId: null,
|
||||||
person: null,
|
person: null,
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
boundingBoxY1: 0,
|
boundingBoxY1: 0,
|
||||||
boundingBoxX2: 1,
|
boundingBoxX2: 1,
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher, tick } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
@ -15,28 +15,40 @@
|
||||||
let element: HTMLVideoElement | undefined = undefined;
|
let element: HTMLVideoElement | undefined = undefined;
|
||||||
let isVideoLoading = true;
|
let isVideoLoading = true;
|
||||||
let assetFileUrl: string;
|
let assetFileUrl: string;
|
||||||
|
let forceMuted = false;
|
||||||
|
|
||||||
$: {
|
$: if (element) {
|
||||||
const next = getAssetPlaybackUrl({ id: assetId, checksum });
|
assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
|
||||||
if (assetFileUrl !== next) {
|
forceMuted = false;
|
||||||
assetFileUrl = next;
|
element.load();
|
||||||
element && element.load();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>();
|
const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>();
|
||||||
|
|
||||||
const handleCanPlay = async (event: Event) => {
|
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||||
try {
|
try {
|
||||||
const video = event.currentTarget as HTMLVideoElement;
|
|
||||||
await video.play();
|
await video.play();
|
||||||
dispatch('onVideoStarted');
|
dispatch('onVideoStarted');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) {
|
||||||
|
await tryForceMutedPlay(video);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleError(error, $t('errors.unable_to_play_video'));
|
handleError(error, $t('errors.unable_to_play_video'));
|
||||||
} finally {
|
} finally {
|
||||||
isVideoLoading = false;
|
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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -51,9 +63,14 @@
|
||||||
playsinline
|
playsinline
|
||||||
controls
|
controls
|
||||||
class="h-full object-contain"
|
class="h-full object-contain"
|
||||||
on:canplay={handleCanPlay}
|
on:canplay={(e) => handleCanPlay(e.currentTarget)}
|
||||||
on:ended={() => dispatch('onVideoEnded')}
|
on:ended={() => dispatch('onVideoEnded')}
|
||||||
bind:muted={$videoViewerMuted}
|
on:volumechange={(e) => {
|
||||||
|
if (!forceMuted) {
|
||||||
|
$videoViewerMuted = e.currentTarget.muted;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
muted={forceMuted || $videoViewerMuted}
|
||||||
bind:volume={$videoViewerVolume}
|
bind:volume={$videoViewerVolume}
|
||||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })}
|
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })}
|
||||||
>
|
>
|
||||||
|
|
|
@ -8,13 +8,14 @@
|
||||||
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
|
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { mdiContentCopy, mdiLink } from '@mdi/js';
|
import { mdiContentCopy, mdiLink } from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import type { ImmichDropDownOption } from '../dropdown-button.svelte';
|
import DropdownButton, { type DropDownOption } from '../dropdown-button.svelte';
|
||||||
import DropdownButton from '../dropdown-button.svelte';
|
|
||||||
import { NotificationType, notificationController } from '../notification/notification';
|
import { NotificationType, notificationController } from '../notification/notification';
|
||||||
import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte';
|
import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte';
|
||||||
import SettingSwitch from '../settings/setting-switch.svelte';
|
import SettingSwitch from '../settings/setting-switch.svelte';
|
||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { DateTime, Duration } from 'luxon';
|
||||||
|
|
||||||
export let onClose: () => void;
|
export let onClose: () => void;
|
||||||
export let albumId: string | undefined = undefined;
|
export let albumId: string | undefined = undefined;
|
||||||
|
@ -26,7 +27,7 @@
|
||||||
let allowDownload = true;
|
let allowDownload = true;
|
||||||
let allowUpload = false;
|
let allowUpload = false;
|
||||||
let showMetadata = true;
|
let showMetadata = true;
|
||||||
let expirationTime = '';
|
let expirationOption: DropDownOption<number> | undefined;
|
||||||
let password = '';
|
let password = '';
|
||||||
let shouldChangeExpirationTime = false;
|
let shouldChangeExpirationTime = false;
|
||||||
let enablePassword = false;
|
let enablePassword = false;
|
||||||
|
@ -35,20 +36,27 @@
|
||||||
created: void;
|
created: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const expiredDateOption: ImmichDropDownOption = {
|
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||||
default: $t('never'),
|
[30, 'minutes'],
|
||||||
options: [
|
[1, 'hour'],
|
||||||
$t('never'),
|
[6, 'hours'],
|
||||||
$t('durations.minutes', { values: { minutes: 30 } }),
|
[1, 'day'],
|
||||||
$t('durations.hours', { values: { hours: 1 } }),
|
[7, 'days'],
|
||||||
$t('durations.hours', { values: { hours: 6 } }),
|
[30, 'days'],
|
||||||
$t('durations.days', { values: { days: 1 } }),
|
[3, 'months'],
|
||||||
$t('durations.days', { values: { days: 7 } }),
|
[1, 'year'],
|
||||||
$t('durations.days', { values: { days: 30 } }),
|
];
|
||||||
$t('durations.months', { values: { months: 3 } }),
|
|
||||||
$t('durations.years', { values: { years: 1 } }),
|
$: relativeTime = new Intl.RelativeTimeFormat($locale);
|
||||||
],
|
$: expiredDateOption = [
|
||||||
};
|
{ label: $t('never'), value: 0 },
|
||||||
|
...expirationOptions.map(
|
||||||
|
([value, unit]): DropDownOption<number> => ({
|
||||||
|
label: relativeTime.format(value, unit),
|
||||||
|
value: Duration.fromObject({ [unit]: value }).toMillis(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
$: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual;
|
$: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual;
|
||||||
$: {
|
$: {
|
||||||
|
@ -74,9 +82,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateSharedLink = async () => {
|
const handleCreateSharedLink = async () => {
|
||||||
const expirationTime = getExpirationTimeInMillisecond();
|
const expirationDate =
|
||||||
const currentTime = Date.now();
|
expirationOption && expirationOption.value > 0 ? DateTime.now().plus(expirationOption.value).toISO() : undefined;
|
||||||
const expirationDate = expirationTime ? new Date(currentTime + expirationTime).toISOString() : undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await createSharedLink({
|
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 () => {
|
const handleEditLink = async () => {
|
||||||
if (!editingLink) {
|
if (!editingLink) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const expirationTime = getExpirationTimeInMillisecond();
|
const expirationDate =
|
||||||
const currentTime = Date.now();
|
expirationOption && expirationOption.value > 0 ? DateTime.now().plus(expirationOption.value).toISO() : null;
|
||||||
const expirationDate: string | null = expirationTime
|
|
||||||
? new Date(currentTime + expirationTime).toISOString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await updateSharedLink({
|
await updateSharedLink({
|
||||||
id: editingLink.id,
|
id: editingLink.id,
|
||||||
|
@ -252,7 +224,7 @@
|
||||||
|
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
options={expiredDateOption}
|
options={expiredDateOption}
|
||||||
bind:selected={expirationTime}
|
bind:selected={expirationOption}
|
||||||
disabled={editingLink && !shouldChangeExpirationTime}
|
disabled={editingLink && !shouldChangeExpirationTime}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -16,10 +16,7 @@
|
||||||
export let onCancel: () => void;
|
export let onCancel: () => void;
|
||||||
export let onConfirm: () => void;
|
export let onConfirm: () => void;
|
||||||
|
|
||||||
let isConfirmButtonDisabled = false;
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
isConfirmButtonDisabled = true;
|
|
||||||
onConfirm();
|
onConfirm();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -37,7 +34,7 @@
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={disabled || isConfirmButtonDisabled}>
|
<Button color={confirmColor} fullwidth on:click={handleConfirm} {disabled}>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
|
|
|
@ -1,21 +1,15 @@
|
||||||
<script lang="ts" context="module">
|
<script lang="ts" context="module">
|
||||||
export type ImmichDropDownOption = {
|
export type DropDownOption<T = unknown> = {
|
||||||
default: string;
|
label: string;
|
||||||
options: string[];
|
value: T;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
export let options: DropDownOption[];
|
||||||
|
export let selected = options.at(0);
|
||||||
export let options: ImmichDropDownOption;
|
|
||||||
export let selected: string;
|
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
selected = options.default;
|
|
||||||
});
|
|
||||||
|
|
||||||
export let isOpen = false;
|
export let isOpen = false;
|
||||||
const toggle = () => (isOpen = !isOpen);
|
const toggle = () => (isOpen = !isOpen);
|
||||||
</script>
|
</script>
|
||||||
|
@ -28,9 +22,11 @@
|
||||||
aria-expanded={isOpen}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div>
|
{#if selected}
|
||||||
{selected}
|
<div>
|
||||||
</div>
|
{selected.label}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<svg
|
<svg
|
||||||
|
@ -51,7 +47,7 @@
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div class="absolute mt-2 flex w-full flex-col">
|
<div class="absolute mt-2 flex w-full flex-col">
|
||||||
{#each options.options as option}
|
{#each options as option}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
@ -60,7 +56,7 @@
|
||||||
}}
|
}}
|
||||||
class="flex w-full bg-gray-200 p-2 transition-all hover:bg-gray-300 dark:bg-gray-500 dark:hover:bg-gray-700"
|
class="flex w-full bg-gray-200 p-2 transition-all hover:bg-gray-300 dark:bg-gray-500 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
{option}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
$preferences = await updateMyPreferences({ userPreferencesUpdateDto: { avatar: { color } } });
|
$preferences = await updateMyPreferences({ userPreferencesUpdateDto: { avatar: { color } } });
|
||||||
|
$user = { ...$user, profileImagePath: '', avatarColor: $preferences.avatar.color };
|
||||||
isShowSelectAvatar = false;
|
isShowSelectAvatar = false;
|
||||||
|
|
||||||
notificationController.show({
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
{#key $user}
|
<UserAvatar user={$user} size="xl" />
|
||||||
<UserAvatar user={$user} size="xl" />
|
|
||||||
{/key}
|
|
||||||
<div class="absolute z-10 bottom-0 right-0 rounded-full w-6 h-6">
|
<div class="absolute z-10 bottom-0 right-0 rounded-full w-6 h-6">
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
color="primary"
|
color="primary"
|
||||||
|
@ -96,6 +95,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
|
||||||
{#if isShowSelectAvatar}
|
{#if isShowSelectAvatar}
|
||||||
<AvatarSelector
|
<AvatarSelector
|
||||||
user={$user}
|
user={$user}
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getProfileImageUrl } from '$lib/utils';
|
import { getProfileImageUrl } from '$lib/utils';
|
||||||
import { type UserAvatarColor } from '@immich/sdk';
|
import { type UserAvatarColor } from '@immich/sdk';
|
||||||
import { onMount, tick } from 'svelte';
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -16,7 +15,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
export let user: User;
|
export let user: User;
|
||||||
export let color: UserAvatarColor = user.avatarColor;
|
export let color: UserAvatarColor | undefined = undefined;
|
||||||
export let size: Size = 'full';
|
export let size: Size = 'full';
|
||||||
export let rounded = true;
|
export let rounded = true;
|
||||||
export let interactive = false;
|
export let interactive = false;
|
||||||
|
@ -27,15 +26,16 @@
|
||||||
let img: HTMLImageElement;
|
let img: HTMLImageElement;
|
||||||
let showFallback = true;
|
let showFallback = true;
|
||||||
|
|
||||||
onMount(async () => {
|
$: img, user, void tryLoadImage();
|
||||||
if (!user.profileImagePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await img.decode();
|
const tryLoadImage = async () => {
|
||||||
await tick();
|
try {
|
||||||
showFallback = false;
|
await img.decode();
|
||||||
});
|
showFallback = false;
|
||||||
|
} catch {
|
||||||
|
showFallback = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const colorClasses: Record<UserAvatarColor, string> = {
|
const colorClasses: Record<UserAvatarColor, string> = {
|
||||||
primary: 'bg-immich-primary dark:bg-immich-dark-primary text-immich-dark-fg dark:text-immich-fg',
|
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',
|
xxxl: 'w-28 h-28',
|
||||||
};
|
};
|
||||||
|
|
||||||
$: colorClass = colorClasses[color];
|
$: colorClass = colorClasses[color || user.avatarColor];
|
||||||
$: sizeClass = sizeClasses[size];
|
$: sizeClass = sizeClasses[size];
|
||||||
$: title = label ?? `${user.name} (${user.email})`;
|
$: title = label ?? `${user.name} (${user.email})`;
|
||||||
$: interactiveClass = interactive
|
$: interactiveClass = interactive
|
||||||
|
|
|
@ -424,13 +424,6 @@
|
||||||
"duplicates": "Duplicates",
|
"duplicates": "Duplicates",
|
||||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates",
|
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates",
|
||||||
"duration": "Duration",
|
"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_album": "Edit album",
|
||||||
"edit_avatar": "Edit avatar",
|
"edit_avatar": "Edit avatar",
|
||||||
"edit_date": "Edit date",
|
"edit_date": "Edit date",
|
||||||
|
|
Loading…
Reference in a new issue