diff --git a/cli/src/api/open-api/base.ts b/cli/src/api/open-api/base.ts index 814dc8f92..9a534e7bd 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 7d762d6ac..8997b2d52 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 3ace8a93f..8058881d1 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 62d163db6..d0651f28a 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 ea948321d..d4c714943 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 bfea5aa3c..da9e271b5 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 aed3b2ec9..5923e5c16 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 7b1cd605f..000000000 --- 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 272724d91..6fea1e5d5 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 cd01d8761..281fa52d2 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 7b7db96a2..a0530d9aa 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 4a77aac1c..69700269c 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 8639b13ad..b15f480e2 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 022cf3886..a6fddc98a 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 5ce704794..19cefc12f 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 c640150e6..c61f1d5d2 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 3ad2f4b62..13eda9d6e 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 f369a35d1..221603ed9 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 df1c8ba6f..f194738a2 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 get formattedDateTime { - final fileCreatedAt = asset.fileCreatedAt.toLocal(); - final date = DateFormat.yMMMEd().format(fileCreatedAt); - final time = DateFormat.jm().format(fileCreatedAt); + String formatTimeZone(Duration d) => + "GMT${d.isNegative ? '-': '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}"; - return '$date • $time'; + String get formattedDateTime { + 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 $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 ec44aaef1..4f6e2706d 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 b1800e8e6..d41022a29 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 568e4ce13..a61fd2c28 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 9122942bd..138e386c7 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; + return (reader.readFloatOrNull(offset)) as P; case 9: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 10: - return (reader.readStringOrNull(offset)) as P; - case 11: 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 b17fce86d..ede113837 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 8ef3c09b5..fa4a73536 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), - fontWeight: FontWeight.bold, - overflow: TextOverflow.ellipsis, + 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, + ), + 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, ), - textAlign: TextAlign.end, ), ), ), diff --git a/mobile/lib/shared/ui/immich_app_bar.dart b/mobile/lib/shared/ui/immich_app_bar.dart index ad8195354..3510931f6 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 8ae91efc2..1af3f1f49 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 fa04c68b5..458538a5d 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 401264c2b..9b6fe1a6c 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 73bfd11b0..3fc34f62e 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 a38797830..6d5aa735d 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 3a02cd1d0..bb4900716 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 7cebecaf8..6eb1fb7f3 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 182d7f7d5..f073c738a 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 894c9b29c..e1a163b81 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 e5af6169a..bacdab317 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 0f5e01320..bacd4bfe6 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 e0e239f6d..9dac7e604 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 c488630ec..5cc86fc6a 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 814dc8f92..9a534e7bd 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 7d762d6ac..8997b2d52 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 3ace8a93f..8058881d1 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 62d163db6..d0651f28a 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 ebffe28d2..20d4820a7 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 8ececaf1d..83da35e70 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 95770a5c4..f80bce9b9 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 750b3247b..222654c59 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 431019f4a..7488ddf6a 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 000000000..45b570c2e --- /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 000000000..8b34359d0 --- /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 1c0db32c9..0199f1cae 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 5eaeb237d..5926f4b6a 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.