diff --git a/cli/src/api/open-api/base.ts b/cli/src/api/open-api/base.ts index 814dc8f9214493161a9ddde2b8a48324c35c00a1..9a534e7bd7a69597badfebd246b4877925afe403 100644 --- a/cli/src/api/open-api/base.ts +++ b/cli/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.83.0 + * The version of the OpenAPI document: 1.84.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/common.ts b/cli/src/api/open-api/common.ts index 7d762d6ac3d75d4e877eea7d60434df3aaeb2ab6..8997b2d529c4be886567cd992404cf6be0c24679 100644 --- a/cli/src/api/open-api/common.ts +++ b/cli/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.83.0 + * The version of the OpenAPI document: 1.84.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/configuration.ts b/cli/src/api/open-api/configuration.ts index 3ace8a93f1de51c556ab1702b66097ad90707bab..8058881d1de443171cc07ea2278e6f18eacaa9e8 100644 --- a/cli/src/api/open-api/configuration.ts +++ b/cli/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.83.0 + * The version of the OpenAPI document: 1.84.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/index.ts b/cli/src/api/open-api/index.ts index 62d163db6b4d6575721f622d545c22e7f2b5233a..d0651f28a803f994464ca0420ecb5fabffd0b22b 100644 --- a/cli/src/api/open-api/index.ts +++ b/cli/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.83.0 + * The version of the OpenAPI document: 1.84.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/docs/blog/2023/06-24/update.mdx b/docs/blog/2023/06-24/update.mdx index ea948321d33aec1656bfa9ffd99c22a1bd176a31..d4c714943916f651ad0c12d07d52a06f8e7f6843 100644 --- a/docs/blog/2023/06-24/update.mdx +++ b/docs/blog/2023/06-24/update.mdx @@ -33,8 +33,6 @@ To be concise, Immich can now read in the gallery files, register the path into - Only new files that are added to the gallery will be detected. - Deleted and moved files will not be detected. -You can find more information on how to use the feature by reading the documentation [here](/docs/features/read-only-gallery). - ## Memory feature This is considered a fun feature that the team and I wanted to build for so long, but we had to put it off because of the refactoring of the code base. The code base is now in a good enough form to circle back and add more exciting features. diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index bfea5aa3ce9690ff7183a9f8d45f713297c5d1a9..da9e271b571747df11fad4832f32129012bd55e4 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -17,13 +17,13 @@ docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/back ``` ```bash title='Restore' -docker-compose down -v # CAUTION! Deletes all Immich data to start from scratch. -docker-compose pull # Update to latest version of Immich (if desired) -docker-compose create # Create Docker containers for Immich apps without running them. +docker compose down -v # CAUTION! Deletes all Immich data to start from scratch. +docker compose pull # Update to latest version of Immich (if desired) +docker compose create # Create Docker containers for Immich apps without running them. docker start immich_postgres # Start Postgres server sleep 10 # Wait for Postgres server to start up gunzip < "/path/to/backup/dump.sql.gz" | docker exec -i immich_postgres psql -U postgres -d immich # Restore Backup -docker-compose up -d # Start remainder of Immich apps +docker compose up -d # Start remainder of Immich apps ``` Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.). diff --git a/docs/docs/features/bulk-upload.md b/docs/docs/features/bulk-upload.md index aed3b2ec991c24feb46a87b51d3dc8a7926f2726..5923e5c1695f591c6e011dc305152beb491b43a2 100644 --- a/docs/docs/features/bulk-upload.md +++ b/docs/docs/features/bulk-upload.md @@ -32,7 +32,6 @@ immich | --server / -s | Immich's server address | | --threads / -t | Number of threads to use (Default 5) | | --album/ -al | Create albums for assets based on the parent folder or a given name | -| --import/ -i | Import gallery (assets are not uploaded) | ## Quick Start @@ -108,70 +107,3 @@ npm run build ```bash title="Run the command" node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory ``` - ---- - -## Importing existing libraries - -If you do not wish to upload files into the server, existing files can be imported into the immich gallery through the use of the `--import` flag. - -``` -immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive directory/ --import -``` - -``` -immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api file1.jpg file2.jpg --import -``` - -The `immich-server` and `immich-microservices` containers must be able to access the files, or directories at the path referenced in the command. The directories referenced must be set under a user's `External Path` setting. More detailed instructions can be found [here](/docs/features/read-only-gallery). - -:::tip Matching volume references -The import command is most easily run on the machine running the immich service, as the path to the files on the machine running the command and the server much match identically. - -If you are running immich within docker, the volume pointing to your existing library should be identical with your host machine. - -```diff title="docker-compose.yml" - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - command: [ "start.sh", "immich" ] - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload -+ - /path/to/media:/path/to/media - env_file: - - .env - depends_on: - - redis - - database - - typesense - restart: always - - immich-microservices: - container_name: immich_microservices - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - command: [ "start.sh", "microservices" ] - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload -+ - /path/to/media:/path/to/media - env_file: - - .env - depends_on: - - redis - - database - - typesense - restart: always -``` - -The proper command for above would be as shown below. You should have access to `/path/to/media` exactly on the environment the CLI command is being run on - -``` -immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import -``` - -If you are running the import using the docker command, please note that the volumes should point to the `/path/to/media` exactly on the environment the CLI command is being run on - -``` -docker run -it --rm -v "/path/to/media:/path/to/media" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import -``` - -::: diff --git a/docs/docs/features/read-only-gallery.md b/docs/docs/features/read-only-gallery.md deleted file mode 100644 index 7b1cd605fa4f24a633765a8ac407bdc18e2578dc..0000000000000000000000000000000000000000 --- a/docs/docs/features/read-only-gallery.md +++ /dev/null @@ -1,97 +0,0 @@ -# Read-only Gallery [Deprecated] - -:::caution - -This feature is being deprecated in favor of [Libraries](/docs/features/libraries.md). - -::: - -## Overview - -This feature enables users to use an existing gallery without uploading the assets to Immich. - -Upon syncing the file information, it will be read by Immich to generate supported files. - -## Usage - -:::tip Example scenario - -On the VM/system that Immich is running, I have 2 galleries that I want to use with Immich. - -- My gallery is stored at `/mnt/media/precious-memory` -- My wife's gallery is stored at `/mnt/media/childhood-memory` - -We will use those values in the steps below. - -::: - -### Mount the gallery to the containers. - -`immich-server` and `immich-microservices` containers will need access to the gallery. Mount the directory path as in the example below - -```diff title="docker-compose.yml" - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - command: [ "start.sh", "immich" ] - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload -+ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro -+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro - env_file: - - .env - depends_on: - - redis - - database - - typesense - restart: always - - immich-microservices: - container_name: immich_microservices - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - command: [ "start.sh", "microservices" ] - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload -+ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro -+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro - env_file: - - .env - depends_on: - - redis - - database - - typesense - restart: always -``` - -:::tip -Internal and external path have to be identical. -::: - -_Remember to bring the container down/up to register the changes. Make sure you can see the mounted path in the container._ - -### Register the path for the user. - -This action is done by the admin of the instance. - -- Navigate to `Administration > Users` page on the web. -- Click on the user edit button. -- Add the gallery path to the `External Path` field for the corresponding user and confirm the changes. - - - - - -### Sync with the CLI tool. - -- Install or update the [CLI Tool](/docs/features/bulk-upload.md). The import feature is supported from version `v0.39.0` of the CLI -- Run the command below to sync the gallery with Immich. - -```bash title="Import my gallery" -immich upload --key --server http://my-server-ip:2283/api /mnt/media/precious-memory --recursive --import -``` - -```bash title="Import my wife gallery" -immich upload --key --server http://my-server-ip:2283/api /mnt/media/childhood-memory --recursive --import -``` - -The `--import` flag will tell Immich to import the files by path instead of uploading them. diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 272724d916aaa60d3da83d6002141b3abbcf2a51..6fea1e5d589d12be7b2481aa364a8c577f05c03a 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.83.0" +version = "1.84.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index cd01d876170c7f87cfddd1103f76630f1378855f..281fa52d248b042fa8ea5802e0fa9908042f0425 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 107, - "android.injected.version.name" => "1.83.0", + "android.injected.version.code" => 108, + "android.injected.version.name" => "1.84.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 7b7db96a28bac97e6cc108d077fcf6b86582c82a..a0530d9aaedde7ffa3a50b00efd2f70cd9ae8cb1 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 4a77aac1c2ab18624812a6d02f9c22eb69562b54..69700269ced4a96dce7d18f2d5736a9d1fb9de8e 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -23,6 +23,7 @@ "album_viewer_appbar_share_err_title": "Failed to change album title", "album_viewer_appbar_share_leave": "Leave album", "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Add users", "all_people_page_title": "People", "all_videos_page_title": "Videos", diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 8639b13adfdd2181caaa22bb59eb7446b8a09975..b15f480e2da528194432f92900f7ab049a93874a 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -379,7 +379,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 118; + CURRENT_PROJECT_VERSION = 124; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -515,7 +515,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 118; + CURRENT_PROJECT_VERSION = 124; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 118; + CURRENT_PROJECT_VERSION = 124; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 022cf38866d3bb1b3230b5ed111595f3da7db010..a6fddc98a708164a2f611dd2b7366cbc201cc7dc 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -59,11 +59,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.78.1 + 1.84.0 CFBundleSignature ???? CFBundleVersion - 118 + 124 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 5ce704794dc313bdb0d7b761ecb2a8d9dfca78cd..19cefc12f3f8388317c2e0413f4fc7ef06bcbf59 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.83.0" + version_number: "1.84.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index c640150e6d3ba8faa9aee27f04cf1a21cb01e254..c61f1d5d286ae8580954333607b336d42ed7569d 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 3ad2f4b62935680ad9d3485012dcb1be1771507f..13eda9d6eac510987a54e0480335c5f4fddbdeef 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; @@ -77,6 +78,8 @@ Future initApp() async { log.severe('Catch all error: ${error.toString()} - $error', error, stack); return true; }; + + initializeTimeZones(); } Future loadDb() async { diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index f369a35d1352656b15addec9538e717af2ba0551..221603ed9097056241abde08a0cb7b1757b22bd8 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -7,6 +7,8 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; +import 'package:immich_mobile/shared/ui/share_dialog.dart'; +import 'package:immich_mobile/shared/services/share.service.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; @@ -160,40 +162,77 @@ class AlbumViewerAppbar extends HookConsumerWidget ImmichLoadingOverlayController.appLoader.hide(); } - buildBottomSheetActionButton() { + void handleShareAssets( + WidgetRef ref, + BuildContext context, + Set selection, + ) { + showDialog( + context: context, + builder: (BuildContext buildContext) { + ref.watch(shareServiceProvider).shareAssets(selection.toList()).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + Navigator.of(buildContext).pop(); + }, + ); + return const ShareDialog(); + }, + barrierDismissible: false, + ); + } + + void onShareAssetsTo() async { + ImmichLoadingOverlayController.appLoader.show(); + handleShareAssets(ref, context, selected); + ImmichLoadingOverlayController.appLoader.hide(); + } + + buildBottomSheetActions() { if (selected.isNotEmpty) { - if (album.ownerId == userId) { - return ListTile( + return [ + ListTile( + leading: const Icon(Icons.ios_share_rounded), + title: const Text( + 'album_viewer_appbar_share_to', + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + onTap: () => onShareAssetsTo(), + ), + album.ownerId == userId ? ListTile( leading: const Icon(Icons.delete_sweep_rounded), title: const Text( 'album_viewer_appbar_share_remove', style: TextStyle(fontWeight: FontWeight.bold), ).tr(), onTap: () => onRemoveFromAlbumPressed(), - ); - } else { - return const SizedBox(); - } + ) : const SizedBox(), + ]; } else { - if (album.ownerId == userId) { - return ListTile( + return [ + album.ownerId == userId ? ListTile( leading: const Icon(Icons.delete_forever_rounded), title: const Text( 'album_viewer_appbar_share_delete', style: TextStyle(fontWeight: FontWeight.bold), ).tr(), onTap: () => onDeleteAlbumPressed(), - ); - } else { - return ListTile( + ) : ListTile( leading: const Icon(Icons.person_remove_rounded), title: const Text( 'album_viewer_appbar_share_leave', style: TextStyle(fontWeight: FontWeight.bold), ).tr(), onTap: () => onLeaveAlbumPressed(), - ); - } + ), + ]; } } @@ -257,7 +296,7 @@ class AlbumViewerAppbar extends HookConsumerWidget child: Column( mainAxisSize: MainAxisSize.min, children: [ - buildBottomSheetActionButton(), + ...buildBottomSheetActions(), if (selected.isEmpty && onAddPhotos != null) ...commonActions, if (selected.isEmpty && onAddPhotos != null && diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index df1c8ba6fb74ff32adfbae9ed6492ae5b2ee7787..f194738a2cd5a7fa0fdc8977588824026edc8a81 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:timezone/timezone.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart'; import 'package:immich_mobile/shared/models/asset.dart'; @@ -26,12 +27,36 @@ class ExifBottomSheet extends HookConsumerWidget { exifInfo.latitude != 0 && exifInfo.longitude != 0; + String formatTimeZone(Duration d) => + "GMT${d.isNegative ? '-': '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}"; + String get formattedDateTime { - final fileCreatedAt = asset.fileCreatedAt.toLocal(); - final date = DateFormat.yMMMEd().format(fileCreatedAt); - final time = DateFormat.jm().format(fileCreatedAt); + DateTime dt = asset.fileCreatedAt.toLocal(); + String? timeZone; + if (asset.exifInfo?.dateTimeOriginal != null) { + dt = asset.exifInfo!.dateTimeOriginal!; + if (asset.exifInfo?.timeZone != null) { + dt = dt.toUtc(); + try { + final location = getLocation(asset.exifInfo!.timeZone!); + dt = TZDateTime.from(dt, location); + } on LocationNotFoundException { + RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false); + final m = re.firstMatch(asset.exifInfo!.timeZone!); + if (m != null) { + final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0')); + dt = dt.add(duration); + timeZone = formatTimeZone(duration); + } + } + } + } + + final date = DateFormat.yMMMEd().format(dt); + final time = DateFormat.jm().format(dt); + timeZone ??= formatTimeZone(dt.timeZoneOffset); - return '$date • $time'; + return '$date • $time $timeZone'; } Future _createCoordinatesUri(ExifInfo? exifInfo) async { diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index ec44aaef155cdb6ef3803ee64c62573390f4a982..4f6e2706d32b870707e16e3e8780a32a8281e830 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -100,7 +100,7 @@ class ControlBottomAppBar extends ConsumerWidget { label: "control_bottom_app_bar_stack".tr(), onPressed: enabled ? onStack : null, ), - if (!hasRemote) + if (hasLocal) ControlBoxButton( iconData: Icons.backup_outlined, label: "Upload", diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index b1800e8e605c6e2e0cc3a1ef65525ecfdb96b6ac..d41022a2989b5fef0f039ad1ca33c67ff500e7b9 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -169,9 +169,10 @@ class HomePage extends HookConsumerWidget { processing.value = true; selectionEnabledHook.value = false; try { - ref - .read(manualUploadProvider.notifier) - .uploadAssets(context, selection.value); + ref.read(manualUploadProvider.notifier).uploadAssets( + context, + selection.value.where((a) => a.storage == AssetState.local), + ); } finally { processing.value = false; } diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart index 568e4ce13a79f279d7be37325906f407312be1e9..a61fd2c289855145b4b3362dbbbb4fab40841f1c 100644 --- a/mobile/lib/shared/models/exif_info.dart +++ b/mobile/lib/shared/models/exif_info.dart @@ -8,6 +8,8 @@ part 'exif_info.g.dart'; class ExifInfo { Id? id; int? fileSize; + DateTime? dateTimeOriginal; + String? timeZone; String? make; String? model; String? lens; @@ -47,6 +49,8 @@ class ExifInfo { ExifInfo.fromDto(ExifResponseDto dto) : fileSize = dto.fileSizeInByte, + dateTimeOriginal = dto.dateTimeOriginal, + timeZone = dto.timeZone, make = dto.make, model = dto.model, lens = dto.lensModel, @@ -64,6 +68,8 @@ class ExifInfo { ExifInfo({ this.id, this.fileSize, + this.dateTimeOriginal, + this.timeZone, this.make, this.model, this.lens, @@ -82,6 +88,8 @@ class ExifInfo { ExifInfo copyWith({ Id? id, int? fileSize, + DateTime? dateTimeOriginal, + String? timeZone, String? make, String? model, String? lens, @@ -99,6 +107,8 @@ class ExifInfo { ExifInfo( id: id ?? this.id, fileSize: fileSize ?? this.fileSize, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + timeZone: timeZone ?? this.timeZone, make: make ?? this.make, model: model ?? this.model, lens: lens ?? this.lens, @@ -119,6 +129,8 @@ class ExifInfo { if (other is! ExifInfo) return false; return id == other.id && fileSize == other.fileSize && + dateTimeOriginal == other.dateTimeOriginal && + timeZone == other.timeZone && make == other.make && model == other.model && lens == other.lens && @@ -139,6 +151,8 @@ class ExifInfo { int get hashCode => id.hashCode ^ fileSize.hashCode ^ + dateTimeOriginal.hashCode ^ + timeZone.hashCode ^ make.hashCode ^ model.hashCode ^ lens.hashCode ^ diff --git a/mobile/lib/shared/models/exif_info.g.dart b/mobile/lib/shared/models/exif_info.g.dart index 9122942bd983d18f907f4d0bcb6eea9dd2376e1d..138e386c79286d2179f18a3085424b9f09677780 100644 --- a/mobile/lib/shared/models/exif_info.g.dart +++ b/mobile/lib/shared/models/exif_info.g.dart @@ -27,65 +27,75 @@ const ExifInfoSchema = CollectionSchema( name: r'country', type: IsarType.string, ), - r'description': PropertySchema( + r'dateTimeOriginal': PropertySchema( id: 2, + name: r'dateTimeOriginal', + type: IsarType.dateTime, + ), + r'description': PropertySchema( + id: 3, name: r'description', type: IsarType.string, ), r'exposureSeconds': PropertySchema( - id: 3, + id: 4, name: r'exposureSeconds', type: IsarType.float, ), r'f': PropertySchema( - id: 4, + id: 5, name: r'f', type: IsarType.float, ), r'fileSize': PropertySchema( - id: 5, + id: 6, name: r'fileSize', type: IsarType.long, ), r'iso': PropertySchema( - id: 6, + id: 7, name: r'iso', type: IsarType.int, ), r'lat': PropertySchema( - id: 7, + id: 8, name: r'lat', type: IsarType.float, ), r'lens': PropertySchema( - id: 8, + id: 9, name: r'lens', type: IsarType.string, ), r'long': PropertySchema( - id: 9, + id: 10, name: r'long', type: IsarType.float, ), r'make': PropertySchema( - id: 10, + id: 11, name: r'make', type: IsarType.string, ), r'mm': PropertySchema( - id: 11, + id: 12, name: r'mm', type: IsarType.float, ), r'model': PropertySchema( - id: 12, + id: 13, name: r'model', type: IsarType.string, ), r'state': PropertySchema( - id: 13, + id: 14, name: r'state', type: IsarType.string, + ), + r'timeZone': PropertySchema( + id: 15, + name: r'timeZone', + type: IsarType.string, ) }, estimateSize: _exifInfoEstimateSize, @@ -150,6 +160,12 @@ int _exifInfoEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.timeZone; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } return bytesCount; } @@ -161,18 +177,20 @@ void _exifInfoSerialize( ) { writer.writeString(offsets[0], object.city); writer.writeString(offsets[1], object.country); - writer.writeString(offsets[2], object.description); - writer.writeFloat(offsets[3], object.exposureSeconds); - writer.writeFloat(offsets[4], object.f); - writer.writeLong(offsets[5], object.fileSize); - writer.writeInt(offsets[6], object.iso); - writer.writeFloat(offsets[7], object.lat); - writer.writeString(offsets[8], object.lens); - writer.writeFloat(offsets[9], object.long); - writer.writeString(offsets[10], object.make); - writer.writeFloat(offsets[11], object.mm); - writer.writeString(offsets[12], object.model); - writer.writeString(offsets[13], object.state); + writer.writeDateTime(offsets[2], object.dateTimeOriginal); + writer.writeString(offsets[3], object.description); + writer.writeFloat(offsets[4], object.exposureSeconds); + writer.writeFloat(offsets[5], object.f); + writer.writeLong(offsets[6], object.fileSize); + writer.writeInt(offsets[7], object.iso); + writer.writeFloat(offsets[8], object.lat); + writer.writeString(offsets[9], object.lens); + writer.writeFloat(offsets[10], object.long); + writer.writeString(offsets[11], object.make); + writer.writeFloat(offsets[12], object.mm); + writer.writeString(offsets[13], object.model); + writer.writeString(offsets[14], object.state); + writer.writeString(offsets[15], object.timeZone); } ExifInfo _exifInfoDeserialize( @@ -184,19 +202,21 @@ ExifInfo _exifInfoDeserialize( final object = ExifInfo( city: reader.readStringOrNull(offsets[0]), country: reader.readStringOrNull(offsets[1]), - description: reader.readStringOrNull(offsets[2]), - exposureSeconds: reader.readFloatOrNull(offsets[3]), - f: reader.readFloatOrNull(offsets[4]), - fileSize: reader.readLongOrNull(offsets[5]), + dateTimeOriginal: reader.readDateTimeOrNull(offsets[2]), + description: reader.readStringOrNull(offsets[3]), + exposureSeconds: reader.readFloatOrNull(offsets[4]), + f: reader.readFloatOrNull(offsets[5]), + fileSize: reader.readLongOrNull(offsets[6]), id: id, - iso: reader.readIntOrNull(offsets[6]), - lat: reader.readFloatOrNull(offsets[7]), - lens: reader.readStringOrNull(offsets[8]), - long: reader.readFloatOrNull(offsets[9]), - make: reader.readStringOrNull(offsets[10]), - mm: reader.readFloatOrNull(offsets[11]), - model: reader.readStringOrNull(offsets[12]), - state: reader.readStringOrNull(offsets[13]), + iso: reader.readIntOrNull(offsets[7]), + lat: reader.readFloatOrNull(offsets[8]), + lens: reader.readStringOrNull(offsets[9]), + long: reader.readFloatOrNull(offsets[10]), + make: reader.readStringOrNull(offsets[11]), + mm: reader.readFloatOrNull(offsets[12]), + model: reader.readStringOrNull(offsets[13]), + state: reader.readStringOrNull(offsets[14]), + timeZone: reader.readStringOrNull(offsets[15]), ); return object; } @@ -213,29 +233,33 @@ P _exifInfoDeserializeProp

( case 1: return (reader.readStringOrNull(offset)) as P; case 2: - return (reader.readStringOrNull(offset)) as P; + return (reader.readDateTimeOrNull(offset)) as P; case 3: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 4: return (reader.readFloatOrNull(offset)) as P; case 5: - return (reader.readLongOrNull(offset)) as P; + return (reader.readFloatOrNull(offset)) as P; case 6: - return (reader.readIntOrNull(offset)) as P; + return (reader.readLongOrNull(offset)) as P; case 7: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readIntOrNull(offset)) as P; case 8: - return (reader.readStringOrNull(offset)) as P; - case 9: return (reader.readFloatOrNull(offset)) as P; - case 10: + case 9: return (reader.readStringOrNull(offset)) as P; - case 11: + case 10: return (reader.readFloatOrNull(offset)) as P; - case 12: + case 11: return (reader.readStringOrNull(offset)) as P; + case 12: + return (reader.readFloatOrNull(offset)) as P; case 13: return (reader.readStringOrNull(offset)) as P; + case 14: + return (reader.readStringOrNull(offset)) as P; + case 15: + return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -622,6 +646,80 @@ extension ExifInfoQueryFilter }); } + QueryBuilder + dateTimeOriginalIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'dateTimeOriginal', + )); + }); + } + + QueryBuilder + dateTimeOriginalIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'dateTimeOriginal', + )); + }); + } + + QueryBuilder + dateTimeOriginalEqualTo(DateTime? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'dateTimeOriginal', + value: value, + )); + }); + } + + QueryBuilder + dateTimeOriginalGreaterThan( + DateTime? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'dateTimeOriginal', + value: value, + )); + }); + } + + QueryBuilder + dateTimeOriginalLessThan( + DateTime? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'dateTimeOriginal', + value: value, + )); + }); + } + + QueryBuilder + dateTimeOriginalBetween( + DateTime? lower, + DateTime? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'dateTimeOriginal', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + QueryBuilder descriptionIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -1956,6 +2054,152 @@ extension ExifInfoQueryFilter )); }); } + + QueryBuilder timeZoneIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'timeZone', + )); + }); + } + + QueryBuilder timeZoneIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'timeZone', + )); + }); + } + + QueryBuilder timeZoneEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'timeZone', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'timeZone', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'timeZone', + value: '', + )); + }); + } + + QueryBuilder timeZoneIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'timeZone', + value: '', + )); + }); + } } extension ExifInfoQueryObject @@ -1989,6 +2233,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByDateTimeOriginal() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.asc); + }); + } + + QueryBuilder sortByDateTimeOriginalDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.desc); + }); + } + QueryBuilder sortByDescription() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'description', Sort.asc); @@ -2132,6 +2388,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { return query.addSortBy(r'state', Sort.desc); }); } + + QueryBuilder sortByTimeZone() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.asc); + }); + } + + QueryBuilder sortByTimeZoneDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.desc); + }); + } } extension ExifInfoQuerySortThenBy @@ -2160,6 +2428,18 @@ extension ExifInfoQuerySortThenBy }); } + QueryBuilder thenByDateTimeOriginal() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.asc); + }); + } + + QueryBuilder thenByDateTimeOriginalDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.desc); + }); + } + QueryBuilder thenByDescription() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'description', Sort.asc); @@ -2315,6 +2595,18 @@ extension ExifInfoQuerySortThenBy return query.addSortBy(r'state', Sort.desc); }); } + + QueryBuilder thenByTimeZone() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.asc); + }); + } + + QueryBuilder thenByTimeZoneDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.desc); + }); + } } extension ExifInfoQueryWhereDistinct @@ -2333,6 +2625,12 @@ extension ExifInfoQueryWhereDistinct }); } + QueryBuilder distinctByDateTimeOriginal() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'dateTimeOriginal'); + }); + } + QueryBuilder distinctByDescription( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -2409,6 +2707,13 @@ extension ExifInfoQueryWhereDistinct return query.addDistinctBy(r'state', caseSensitive: caseSensitive); }); } + + QueryBuilder distinctByTimeZone( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'timeZone', caseSensitive: caseSensitive); + }); + } } extension ExifInfoQueryProperty @@ -2431,6 +2736,13 @@ extension ExifInfoQueryProperty }); } + QueryBuilder + dateTimeOriginalProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'dateTimeOriginal'); + }); + } + QueryBuilder descriptionProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'description'); @@ -2502,4 +2814,10 @@ extension ExifInfoQueryProperty return query.addPropertyName(r'state'); }); } + + QueryBuilder timeZoneProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'timeZone'); + }); + } } diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart index b17fce86d3a771d6aaa04323d675a48bfe4d51fe..ede1138379cfe210cb4b7ed96f180ce24b93a4ff 100644 --- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart @@ -194,6 +194,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { Navigator.of(context).pop(); launchUrl( Uri.parse('https://immich.app'), + mode: LaunchMode.externalApplication, ); }, child: Text( @@ -213,6 +214,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { Navigator.of(context).pop(); launchUrl( Uri.parse('https://github.com/immich-app/immich'), + mode: LaunchMode.externalApplication, ); }, child: Text( diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart index 8ef3c09b5ab1da8cc9fefa3fe041c3a224d59a86..fa4a73536e46d0ed6c2354a3cc42e7c053462f3f 100644 --- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart +++ b/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart @@ -182,19 +182,36 @@ class AppBarServerInfo extends HookConsumerWidget { child: Container( width: 200, padding: const EdgeInsets.only(right: 10.0), - child: Text( - getServerUrl() ?? '--', - style: TextStyle( - fontSize: 11, - color: Theme.of(context) - .textTheme - .labelSmall - ?.color - ?.withOpacity(0.5), + child: Tooltip( + verticalOffset: 0, + decoration: BoxDecoration( + color: + Theme.of(context).primaryColor.withOpacity(0.9), + borderRadius: BorderRadius.circular(10), + ), + textStyle: TextStyle( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.black + : Colors.white, fontWeight: FontWeight.bold, - overflow: TextOverflow.ellipsis, ), - textAlign: TextAlign.end, + message: getServerUrl() ?? '--', + preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + child: Text( + getServerUrl() ?? '--', + style: TextStyle( + fontSize: 11, + color: Theme.of(context) + .textTheme + .labelSmall + ?.color + ?.withOpacity(0.5), + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + textAlign: TextAlign.end, + ), ), ), ), diff --git a/mobile/lib/shared/ui/immich_app_bar.dart b/mobile/lib/shared/ui/immich_app_bar.dart index ad8195354bff77ab13f62c0da1e13f704334f778..3510931f6bdab9761e138b16864083195ec54161 100644 --- a/mobile/lib/shared/ui/immich_app_bar.dart +++ b/mobile/lib/shared/ui/immich_app_bar.dart @@ -31,7 +31,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { final isDarkMode = Theme.of(context).brightness == Brightness.dark; const widgetSize = 30.0; - buildProfilePhoto() { + buildProfileIndicator() { return InkWell( onTap: () => showDialog( context: context, @@ -39,37 +39,33 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { builder: (ctx) => const ImmichAppBarDialog(), ), borderRadius: BorderRadius.circular(12), - child: authState.profileImagePath.isEmpty || user == null - ? const Icon( - Icons.face_outlined, - size: widgetSize, - ) - : UserCircleAvatar( - radius: 15, - size: 27, - user: user, - ), - ); - } - - buildProfileIndicator() { - return Badge( - label: Container( - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(widgetSize / 2), - ), - child: const Icon( - Icons.info, - color: Color.fromARGB(255, 243, 188, 106), - size: widgetSize / 2, + child: Badge( + label: Container( + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(widgetSize / 2), + ), + child: const Icon( + Icons.info, + color: Color.fromARGB(255, 243, 188, 106), + size: widgetSize / 2, + ), ), + backgroundColor: Colors.transparent, + alignment: Alignment.bottomRight, + isLabelVisible: serverInfoState.isVersionMismatch, + offset: const Offset(2, 2), + child: authState.profileImagePath.isEmpty || user == null + ? const Icon( + Icons.face_outlined, + size: widgetSize, + ) + : UserCircleAvatar( + radius: 15, + size: 27, + user: user, + ), ), - backgroundColor: Colors.transparent, - alignment: Alignment.bottomRight, - isLabelVisible: serverInfoState.isVersionMismatch, - offset: const Offset(2, 2), - child: buildProfilePhoto(), ); } diff --git a/mobile/openapi/doc/ActivityApi.md b/mobile/openapi/doc/ActivityApi.md index 8ae91efc29d8ea444a0d1712e516a60e03f214c3..1af3f1f496f70a1d437255561d548627878fcea8 100644 --- a/mobile/openapi/doc/ActivityApi.md +++ b/mobile/openapi/doc/ActivityApi.md @@ -125,7 +125,7 @@ void (empty response body) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getActivities** -> List getActivities(albumId, assetId, type) +> List getActivities(albumId, assetId, type, userId) @@ -151,9 +151,10 @@ final api_instance = ActivityApi(); final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final type = ; // ReactionType | +final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | try { - final result = api_instance.getActivities(albumId, assetId, type); + final result = api_instance.getActivities(albumId, assetId, type, userId); print(result); } catch (e) { print('Exception when calling ActivityApi->getActivities: $e\n'); @@ -167,6 +168,7 @@ Name | Type | Description | Notes **albumId** | **String**| | **assetId** | **String**| | [optional] **type** | [**ReactionType**](.md)| | [optional] + **userId** | **String**| | [optional] ### Return type diff --git a/mobile/openapi/lib/api/activity_api.dart b/mobile/openapi/lib/api/activity_api.dart index fa04c68b5ac6b41c94927bd0dca24ca62496d08a..458538a5d28d14715f9238c2ad6c3ddfb569849e 100644 --- a/mobile/openapi/lib/api/activity_api.dart +++ b/mobile/openapi/lib/api/activity_api.dart @@ -111,7 +111,9 @@ class ActivityApi { /// * [String] assetId: /// /// * [ReactionType] type: - Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, }) async { + /// + /// * [String] userId: + Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, String? userId, }) async { // ignore: prefer_const_declarations final path = r'/activity'; @@ -129,6 +131,9 @@ class ActivityApi { if (type != null) { queryParams.addAll(_queryParams('', 'type', type)); } + if (userId != null) { + queryParams.addAll(_queryParams('', 'userId', userId)); + } const contentTypes = []; @@ -151,8 +156,10 @@ class ActivityApi { /// * [String] assetId: /// /// * [ReactionType] type: - Future?> getActivities(String albumId, { String? assetId, ReactionType? type, }) async { - final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, ); + /// + /// * [String] userId: + Future?> getActivities(String albumId, { String? assetId, ReactionType? type, String? userId, }) async { + final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, userId: userId, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/test/activity_api_test.dart b/mobile/openapi/test/activity_api_test.dart index 401264c2bb7ad85d934eaa94df75a02b6ee7a8d7..9b6fe1a6ca8090386131dad2ce93afb240bb13ab 100644 --- a/mobile/openapi/test/activity_api_test.dart +++ b/mobile/openapi/test/activity_api_test.dart @@ -27,7 +27,7 @@ void main() { // TODO }); - //Future> getActivities(String albumId, { String assetId, ReactionType type }) async + //Future> getActivities(String albumId, { String assetId, ReactionType type, String userId }) async test('test getActivities', () async { // TODO }); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 73bfd11b08bd4e2c641e8d789e3202234bbbf4b4..3fc34f62e1293d9c28620e6cf8ab2820cefca57f 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1401,7 +1401,7 @@ packages: source: hosted version: "2.1.3" timezone: - dependency: transitive + dependency: "direct main" description: name: timezone sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a387978301818d8f78a14fe539cfd22a8bae2e98..6d5aa735d61188a59b455a6e9885f15bbbf07f49 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.83.0+107 +version: 1.84.0+108 isar_version: &isar_version 3.1.0+1 environment: @@ -52,6 +52,7 @@ dependencies: crypto: ^3.0.3 # TODO remove once native crypto is used on iOS wakelock_plus: ^1.1.1 flutter_local_notifications: ^15.1.0+1 + timezone: ^0.9.2 openapi: path: openapi diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 3a02cd1d0f4afe5c197a38b25f611df519210032..bb4900716e715c228cfa2c701e4673c78edb9d97 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -30,6 +30,15 @@ "schema": { "$ref": "#/components/schemas/ReactionType" } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { @@ -5635,7 +5644,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.83.0", + "version": "1.84.0", "contact": {} }, "tags": [], diff --git a/server/package-lock.json b/server/package-lock.json index 7cebecaf88482c7a2db37b9f1b1b6d70cb584c67..6eb1fb7f3be27e14dc1fdf9a78ebef71d8694082 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.83.0", + "version": "1.84.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.83.0", + "version": "1.84.0", "license": "UNLICENSED", "dependencies": { "@babel/runtime": "^7.22.11", diff --git a/server/package.json b/server/package.json index 182d7f7d532a98e8f819592283ef20a2f035d802..f073c738a57b3b7a51d9143fe3cd5112efef950c 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.83.0", + "version": "1.84.0", "description": "", "author": "", "private": true, diff --git a/server/src/domain/activity/activity.dto.ts b/server/src/domain/activity/activity.dto.ts index 894c9b29c0643c5e822f6b0f325015da16e1ceec..e1a163b8147b4616d1dd7d676e0defbbcc2e8ea6 100644 --- a/server/src/domain/activity/activity.dto.ts +++ b/server/src/domain/activity/activity.dto.ts @@ -38,6 +38,9 @@ export class ActivitySearchDto extends ActivityDto { @Optional() @ApiProperty({ enumName: 'ReactionType', enum: ReactionType }) type?: ReactionType; + + @ValidateUUID({ optional: true }) + userId?: string; } const isComment = (dto: ActivityCreateDto) => dto.type === 'comment'; diff --git a/server/src/domain/activity/activity.service.ts b/server/src/domain/activity/activity.service.ts index e5af6169a14f8c4e62cfda5ac8e191b226f80df4..bacdab317f9c16cd98c386d042c365da8e5536cf 100644 --- a/server/src/domain/activity/activity.service.ts +++ b/server/src/domain/activity/activity.service.ts @@ -28,6 +28,7 @@ export class ActivityService { async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise { await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId); const activities = await this.repository.search({ + userId: dto.userId, albumId: dto.albumId, assetId: dto.assetId, isLiked: dto.type && dto.type === ReactionType.LIKE, diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 0f5e013202bc6fdbcde2a5320d29ce48d911c0c8..bacd4bfe683138265fa2b989de4e1036b8bab743 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -98,7 +98,14 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As tags: entity.tags?.map(mapTag), people: entity.faces ?.map(mapFace) - .filter((person): person is PersonResponseDto => person !== null && !person.isHidden), + .filter((person): person is PersonResponseDto => person !== null && !person.isHidden) + .reduce((people, person) => { + const existingPerson = people.find((p) => p.id === person.id); + if (!existingPerson) { + people.push(person); + } + return people; + }, [] as PersonResponseDto[]), checksum: entity.checksum.toString('base64'), stackParentId: entity.stackParentId, stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined, diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index e0e239f6dda7099db26cf4af671595d9a564a7e4..9dac7e604ec4b979081cc6163f1d185229fab86a 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -109,7 +109,9 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, - stack: true, + stack: { + exifInfo: true, + }, }, // We are specifically asking for this asset. Return it even if it is soft deleted withDeleted: true, diff --git a/server/test/e2e/activity.e2e-spec.ts b/server/test/e2e/activity.e2e-spec.ts index c488630ec813fbe49bed84d50062fa21a2f1a062..5cc86fc6aa0f6cbee167887a798085ae64a7b9c7 100644 --- a/server/test/e2e/activity.e2e-spec.ts +++ b/server/test/e2e/activity.e2e-spec.ts @@ -134,6 +134,29 @@ describe(`${ActivityController.name} (e2e)`, () => { expect(body[0]).toEqual(reaction); }); + it('should filter by userId', async () => { + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), + ]); + + const response1 = await request(server) + .get('/activity') + .query({ albumId: album.id, userId: uuidStub.notFound }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(response1.status).toEqual(200); + expect(response1.body.length).toBe(0); + + const response2 = await request(server) + .get('/activity') + .query({ albumId: album.id, userId: admin.userId }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(response2.status).toEqual(200); + expect(response2.body.length).toBe(1); + expect(response2.body[0]).toEqual(reaction); + }); + it('should filter by assetId', async () => { const [reaction] = await Promise.all([ api.activityApi.create(server, admin.accessToken, { diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index 814dc8f9214493161a9ddde2b8a48324c35c00a1..9a534e7bd7a69597badfebd246b4877925afe403 100644 --- a/web/src/api/open-api/base.ts +++ b/web/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.83.0 + * The version of the OpenAPI document: 1.84.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index 7d762d6ac3d75d4e877eea7d60434df3aaeb2ab6..8997b2d529c4be886567cd992404cf6be0c24679 100644 --- a/web/src/api/open-api/common.ts +++ b/web/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.83.0 + * The version of the OpenAPI document: 1.84.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index 3ace8a93f1de51c556ab1702b66097ad90707bab..8058881d1de443171cc07ea2278e6f18eacaa9e8 100644 --- a/web/src/api/open-api/configuration.ts +++ b/web/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.83.0 + * The version of the OpenAPI document: 1.84.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index 62d163db6b4d6575721f622d545c22e7f2b5233a..d0651f28a803f994464ca0420ecb5fabffd0b22b 100644 --- a/web/src/api/open-api/index.ts +++ b/web/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.83.0 + * The version of the OpenAPI document: 1.84.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index ebffe28d2bb984a7b945e81529df74c333925af8..20d4820a7b13a8e5899dd59281bb19af2986fd95 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -32,7 +32,7 @@ export let showDownloadButton: boolean; export let showDetailButton: boolean; export let showSlideshow = false; - export let hasStackChildern = false; + export let hasStackChildren = false; $: isOwner = asset.ownerId === $page.data.user?.id; @@ -176,7 +176,7 @@ /> onMenuClick('asProfileImage')} text="As profile picture" /> - {#if hasStackChildern} + {#if hasStackChildren} onMenuClick('unstack')} text="Un-Stack" /> {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 8ececaf1d2863a451a1098b53df472940379b0ab..83da35e70a8c6ff878181128a92ff75c79f6b82d 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -29,25 +29,24 @@ import { browser } from '$app/environment'; import { handleError } from '$lib/utils/handle-error'; import type { AssetStore } from '$lib/stores/assets.store'; - import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; - import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { featureFlags } from '$lib/stores/server-config.store'; import { - mdiChevronLeft, mdiHeartOutline, mdiHeart, mdiCommentOutline, + mdiChevronLeft, mdiChevronRight, - mdiClose, mdiImageBrokenVariant, - mdiPause, - mdiPlay, } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; import ActivityViewer from './activity-viewer.svelte'; + import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; + import SlideshowBar from './slideshow-bar.svelte'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; @@ -62,6 +61,14 @@ let reactions: ActivityResponseDto[] = []; + const { setAssetId } = assetViewingStore; + const { + restartProgress: restartSlideshowProgress, + stopProgress: stopSlideshowProgress, + slideshowShuffle, + slideshowState, + } = slideshowStore; + const dispatch = createEventDispatcher<{ archived: AssetResponseDto; unarchived: AssetResponseDto; @@ -82,6 +89,8 @@ let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; let shouldShowDetailButton = asset.hasMetadata; let canCopyImagesToClipboard: boolean; + let slideshowStateUnsubscribe: () => void; + let shuffleSlideshowUnsubscribe: () => void; let previewStackedAsset: AssetResponseDto | undefined; let isShowActivity = false; let isLiked: ActivityResponseDto | null = null; @@ -123,15 +132,18 @@ }; const getFavorite = async () => { - if (album) { + if (album && user) { try { const { data } = await api.activityApi.getActivities({ + userId: user.id, assetId: asset.id, albumId: album.id, type: ReactionType.Like, }); if (data.length > 0) { isLiked = data[0]; + } else { + isLiked = null; } } catch (error) { handleError(error, "Can't get Favorite"); @@ -161,6 +173,23 @@ onMount(async () => { document.addEventListener('keydown', onKeyboardPress); + slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(asset.id); + handlePlaySlideshow(); + } else if (value === SlideshowState.StopSlideshow) { + handleStopSlideshow(); + } + }); + + shuffleSlideshowUnsubscribe = slideshowShuffle.subscribe((value) => { + if (value) { + slideshowHistory.reset(); + slideshowHistory.queue(asset.id); + } + }); + if (!sharedLink) { await getAllAlbums(); } @@ -184,6 +213,14 @@ if (browser) { document.removeEventListener('keydown', onKeyboardPress); } + + if (slideshowStateUnsubscribe) { + slideshowStateUnsubscribe(); + } + + if (shuffleSlideshowUnsubscribe) { + shuffleSlideshowUnsubscribe(); + } }); $: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes @@ -262,11 +299,31 @@ const closeViewer = () => dispatch('close'); + const navigateAssetRandom = async () => { + if (!assetStore) { + return; + } + + const asset = await assetStore.getRandomAsset(); + if (!asset) { + return; + } + + slideshowHistory.queue(asset.id); + + setAssetId(asset.id); + $restartSlideshowProgress = true; + }; + const navigateAssetForward = async (e?: Event) => { - if (isSlideshowMode && assetStore && progressBar) { + if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) { + return slideshowHistory.next() || navigateAssetRandom(); + } + + if ($slideshowState === SlideshowState.PlaySlideshow && assetStore) { const hasNext = await assetStore.getNextAssetId(asset.id); if (hasNext) { - progressBar.restart(true); + $restartSlideshowProgress = true; } else { await handleStopSlideshow(); } @@ -277,8 +334,13 @@ }; const navigateAssetBackward = (e?: Event) => { - if (isSlideshowMode && progressBar) { - progressBar.restart(true); + if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) { + slideshowHistory.previous(); + return; + } + + if ($slideshowState === SlideshowState.PlaySlideshow) { + $restartSlideshowProgress = true; } e?.stopPropagation(); @@ -426,19 +488,21 @@ * Slide show mode */ - let isSlideshowMode = false; let assetViewerHtmlElement: HTMLElement; - let progressBar: ProgressBar; - let progressBarStatus: ProgressBarStatus; + + const slideshowHistory = new SlideshowHistory((assetId: string) => { + setAssetId(assetId); + $restartSlideshowProgress = true; + }); const handleVideoStarted = () => { - if (isSlideshowMode) { - progressBar.restart(false); + if ($slideshowState === SlideshowState.PlaySlideshow) { + $stopSlideshowProgress = true; } }; const handleVideoEnded = async () => { - if (isSlideshowMode) { + if ($slideshowState === SlideshowState.PlaySlideshow) { await navigateAssetForward(); } }; @@ -448,19 +512,20 @@ await assetViewerHtmlElement.requestFullscreen(); } catch (error) { console.error('Error entering fullscreen', error); - } finally { - isSlideshowMode = true; + $slideshowState = SlideshowState.StopSlideshow; } }; const handleStopSlideshow = async () => { try { - await document.exitFullscreen(); + if (document.fullscreenElement) { + await document.exitFullscreen(); + } } catch (error) { console.error('Error exiting fullscreen', error); } finally { - isSlideshowMode = false; - progressBar.restart(false); + $stopSlideshowProgress = true; + $slideshowState = SlideshowState.None; } }; @@ -484,7 +549,7 @@ } asset.stackCount = 0; asset.stack = []; - assetStore?.updateAsset(asset); + assetStore?.updateAsset(asset, true); dispatch('unstack'); notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 }); @@ -497,31 +562,10 @@

-
- {#if isSlideshowMode} - -
-
- - (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} - title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'} - /> - - -
- -
- {:else} + + {#if $slideshowState === SlideshowState.None} +
0} + hasStackChildren={$stackAssetsStore.length > 0} on:goBack={closeViewer} on:showDetail={showDetailInfoHandler} on:download={() => downloadFile(asset)} @@ -544,19 +588,30 @@ on:toggleArchive={toggleArchive} on:asProfileImage={() => (isShowProfileImageCrop = true)} on:runJob={({ detail: job }) => handleRunJob(job)} - on:playSlideShow={handlePlaySlideshow} + on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)} on:unstack={handleUnstack} /> - {/if} -
+
+ {/if} - {#if !isSlideshowMode && showNavigation} -
+ {#if $slideshowState === SlideshowState.None && showNavigation} +
{/if} + -
+
+ {#if $slideshowState != SlideshowState.None} +
+ ($slideshowState = SlideshowState.StopSlideshow)} + /> +
+ {/if} + {#if previewStackedAsset} {#key previewStackedAsset.id} {#if previewStackedAsset.type === AssetTypeEnum.Image} @@ -602,7 +657,7 @@ on:onVideoStarted={handleVideoStarted} /> {/if} - {#if isShared} + {#if $slideshowState === SlideshowState.None && isShared}
- - - {#if !isSlideshowMode && showNavigation} -
+ {#if $slideshowState === SlideshowState.None && showNavigation} +
{/if} - {#if !isSlideshowMode && $isShowDetail} + {#if $slideshowState === SlideshowState.None && $isShowDetail}
+ import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; + import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte'; + import { slideshowStore } from '$lib/stores/slideshow.store'; + import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import { + mdiChevronLeft, + mdiChevronRight, + mdiClose, + mdiPause, + mdiPlay, + mdiShuffle, + mdiShuffleDisabled, + } from '@mdi/js'; + + const { slideshowShuffle } = slideshowStore; + const { restartProgress, stopProgress } = slideshowStore; + + let progressBarStatus: ProgressBarStatus; + let progressBar: ProgressBar; + + let unsubscribeRestart: () => void; + let unsubscribeStop: () => void; + + const dispatch = createEventDispatcher<{ + next: void; + prev: void; + close: void; + }>(); + + onMount(() => { + unsubscribeRestart = restartProgress.subscribe((value) => { + if (value) { + progressBar.restart(value); + } + }); + + unsubscribeStop = stopProgress.subscribe((value) => { + if (value) { + progressBar.restart(false); + } + }); + }); + + onDestroy(() => { + if (unsubscribeRestart) { + unsubscribeRestart(); + } + + if (unsubscribeStop) { + unsubscribeStop(); + } + }); + + +
+ dispatch('close')} title="Exit Slideshow" /> + {#if $slideshowShuffle} + ($slideshowShuffle = false)} title="Shuffle" /> + {:else} + ($slideshowShuffle = true)} title="No shuffle" /> + {/if} + (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} + title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'} + /> + dispatch('prev')} title="Previous" /> + dispatch('next')} title="Next" /> +
+ + dispatch('next')} + duration={5000} +/> diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 95770a5c492c9742eb6078622e2de1d8abd16d01..f80bce9b9a095662617494b6e11bd2538cbd93ba 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -12,7 +12,7 @@ export let scrollbar = true; export let admin = false; - $: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden pl-4'; + $: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden'; $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'; diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte index 750b3247bacc68b2047cb827b6ea4ef937cc7e95..222654c59314bcb529e979da893682cf18d8ad40 100644 --- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte +++ b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte @@ -21,11 +21,27 @@ $: hoverY = height - windowHeight + clientY; $: scrollY = toScrollY(timelineY); - $: segments = $assetStore.buckets.map((bucket) => ({ - count: bucket.assets.length, - height: toScrollY(bucket.bucketHeight), - timeGroup: bucket.bucketDate, - })); + + class Segment { + public count; + public height; + public timeGroup; + + constructor({ count = 0, height = 0, timeGroup = '' }) { + this.count = count; + this.height = height; + this.timeGroup = timeGroup; + } + } + + $: segments = $assetStore.buckets.map( + (bucket) => + new Segment({ + count: bucket.assets.length, + height: toScrollY(bucket.bucketHeight), + timeGroup: bucket.bucketDate, + }), + ); const dispatch = createEventDispatcher<{ scrollTimeline: number }>(); const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY)); @@ -51,6 +67,23 @@ isAnimating = false; }); }; + + const prevYearSegmentHeight = (segments: Segment[], index: number) => { + var prevYear = null; + var height = 0; + for (var i = index; i >= 0; i--) { + const curr = segments[i]; + const currYear = fromLocalDateTime(curr.timeGroup).year; + if (prevYear && prevYear != currYear) { + break; + } + + height += curr.height; + prevYear = currYear; + } + + return height; + }; @@ -96,6 +129,7 @@ {@const date = fromLocalDateTime(segment.timeGroup)} {@const year = date.year} {@const label = `${date.toLocaleString({ month: 'short' })} ${year}`} + {@const lastGroupYear = fromLocalDateTime(segments[index - 1]?.timeGroup).year}
(hoverLabel = label)} > - {#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year} + {#if lastGroupYear !== year && (index == 0 || prevYearSegmentHeight(segments, index - 1) > 16)}
{year}
diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 431019f4a38cde50f0f432203a3233388ee0ef3b..7488ddf6a6d4afd5503d3e88a167d9b0e9eace43 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -304,7 +304,20 @@ export class AssetStore { return this.assetToBucket[assetId]?.bucketIndex ?? null; } - updateAsset(_asset: AssetResponseDto) { + async getRandomAsset(): Promise { + const bucket = this.buckets[Math.floor(Math.random() * this.buckets.length)] || null; + if (!bucket) { + return null; + } + + if (bucket.assets.length === 0) { + await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + } + + return bucket.assets[Math.floor(Math.random() * bucket.assets.length)] || null; + } + + updateAsset(_asset: AssetResponseDto, recalculate = false) { const asset = this.assets.find((asset) => asset.id === _asset.id); if (!asset) { return; @@ -312,7 +325,7 @@ export class AssetStore { Object.assign(asset, _asset); - this.emit(false); + this.emit(recalculate); } removeAssets(ids: string[]) { diff --git a/web/src/lib/stores/slideshow.store.ts b/web/src/lib/stores/slideshow.store.ts new file mode 100644 index 0000000000000000000000000000000000000000..45b570c2e24af64597328df4aac55212feb767d7 --- /dev/null +++ b/web/src/lib/stores/slideshow.store.ts @@ -0,0 +1,45 @@ +import { persisted } from 'svelte-local-storage-store'; +import { writable } from 'svelte/store'; + +export enum SlideshowState { + PlaySlideshow = 'play-slideshow', + StopSlideshow = 'stop-slideshow', + None = 'none', +} + +function createSlideshowStore() { + const restartState = writable(false); + const stopState = writable(false); + + const slideshowShuffle = persisted('slideshow-shuffle', true); + const slideshowState = writable(SlideshowState.None); + + return { + restartProgress: { + subscribe: restartState.subscribe, + set: (value: boolean) => { + // Trigger an action whenever the restartProgress is set to true. Automatically + // reset the restart state after that + if (value) { + restartState.set(true); + restartState.set(false); + } + }, + }, + stopProgress: { + subscribe: stopState.subscribe, + set: (value: boolean) => { + // Trigger an action whenever the stopProgress is set to true. Automatically + // reset the stop state after that + if (value) { + stopState.set(true); + stopState.set(false); + } + }, + }, + slideshowShuffle, + slideshowState, + }; +} + +export const slideshowStore = createSlideshowStore(); diff --git a/web/src/lib/utils/slideshow-history.ts b/web/src/lib/utils/slideshow-history.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b34359d0b602882d0f512a290f21675b6923f79 --- /dev/null +++ b/web/src/lib/utils/slideshow-history.ts @@ -0,0 +1,40 @@ +export class SlideshowHistory { + private history: string[] = []; + private index = 0; + + constructor(private onChange: (assetId: string) => void) {} + + reset() { + this.history = []; + this.index = 0; + } + + queue(assetId: string) { + this.history.push(assetId); + + // If we were at the end of the slideshow history, move the index to the new end + if (this.index === this.history.length - 2) { + this.index++; + } + } + + next(): boolean { + if (this.index === this.history.length - 1) { + return false; + } + + this.index++; + this.onChange(this.history[this.index]); + return true; + } + + previous(): boolean { + if (this.index === 0) { + return false; + } + + this.index--; + this.onChange(this.history[this.index]); + return true; + } +} diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 1c0db32c9128634317e9caf19ab04c063190a0dc..0199f1caea7ad56b04e446016bf9e544bb856132 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -29,6 +29,7 @@ import { AppRoute, dateFormats } from '$lib/constants'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { AssetStore } from '$lib/stores/assets.store'; import { locale } from '$lib/stores/preferences.store'; import { downloadArchive } from '$lib/utils/asset-utils'; @@ -52,7 +53,8 @@ export let data: PageData; - let { isViewing: showAssetViewer } = assetViewingStore; + let { isViewing: showAssetViewer, setAssetId } = assetViewingStore; + let { slideshowState, slideshowShuffle } = slideshowStore; let album = data.album; $: album = data.album; @@ -108,6 +110,14 @@ } }); + const handleStartSlideshow = async () => { + const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0]; + if (asset) { + setAssetId(asset.id); + $slideshowState = SlideshowState.PlaySlideshow; + } + }; + const handleEscape = () => { if (viewMode === ViewMode.SELECT_USERS) { viewMode = ViewMode.VIEW; @@ -365,6 +375,9 @@ {#if viewMode === ViewMode.ALBUM_OPTIONS} + {#if album.assetCount !== 0} + + {/if} (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" /> {/if} diff --git a/web/src/routes/(user)/trash/+page.svelte b/web/src/routes/(user)/trash/+page.svelte index 5eaeb237d2a57513a177e5b87ec9b4d95c8bae72..5926f4b6a72ada43f40652f9abfa8386148ea85d 100644 --- a/web/src/routes/(user)/trash/+page.svelte +++ b/web/src/routes/(user)/trash/+page.svelte @@ -87,7 +87,7 @@
-

+

Trashed items will be permanently deleted after {$serverConfig.trashDays} days.