merge main
This commit is contained in:
commit
fb9b854bf1
53 changed files with 978 additions and 398 deletions
2
cli/src/api/open-api/base.ts
generated
2
cli/src/api/open-api/base.ts
generated
|
@ -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).
|
||||
|
|
2
cli/src/api/open-api/common.ts
generated
2
cli/src/api/open-api/common.ts
generated
|
@ -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).
|
||||
|
|
2
cli/src/api/open-api/configuration.ts
generated
2
cli/src/api/open-api/configuration.ts
generated
|
@ -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).
|
||||
|
|
2
cli/src/api/open-api/index.ts
generated
2
cli/src/api/open-api/index.ts
generated
|
@ -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).
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.).
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
||||
:::
|
||||
|
|
|
@ -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.
|
||||
|
||||
<img src={require('./img/me.png').default} width='33%' title='My Account Storage Path' />
|
||||
|
||||
<img src={require('./img/my-wife.png').default} width='33%' title='My Wifes Account Storage Path' />
|
||||
|
||||
### 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 <my-api-key> --server http://my-server-ip:2283/api /mnt/media/precious-memory --recursive --import
|
||||
```
|
||||
|
||||
```bash title="Import my wife gallery"
|
||||
immich upload --key <my-wife-api-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.
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.83.0"
|
||||
version = "1.84.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -5,17 +5,17 @@
|
|||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000625">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.160108">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="70.943413">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="39.176668">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.374484">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -59,11 +59,11 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.78.1</string>
|
||||
<string>1.84.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>118</string>
|
||||
<string>124</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -5,32 +5,32 @@
|
|||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="7.645306">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.181977">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.669798">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="16.12614">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.218788">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.162663">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="97.596654">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="145.399278">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="89.490906">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="61.317235">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
|
|
@ -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<void> initApp() async {
|
|||
log.severe('Catch all error: ${error.toString()} - $error', error, stack);
|
||||
return true;
|
||||
};
|
||||
|
||||
initializeTimeZones();
|
||||
}
|
||||
|
||||
Future<Isar> loadDb() async {
|
||||
|
|
|
@ -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<Asset> 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 &&
|
||||
|
|
|
@ -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<Uri?> _createCoordinatesUri(ExifInfo? exifInfo) async {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 ^
|
||||
|
|
|
@ -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<P>(
|
|||
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<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||
dateTimeOriginalIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'dateTimeOriginal',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||
dateTimeOriginalIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'dateTimeOriginal',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||
dateTimeOriginalEqualTo(DateTime? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'dateTimeOriginal',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||
dateTimeOriginalGreaterThan(
|
||||
DateTime? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'dateTimeOriginal',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||
dateTimeOriginalLessThan(
|
||||
DateTime? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'dateTimeOriginal',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||
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<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
|
@ -1956,6 +2054,152 @@ extension ExifInfoQueryFilter
|
|||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'timeZone',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'timeZone',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneEqualTo(
|
||||
String? value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'timeZone',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> 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<ExifInfo, ExifInfo, QAfterFilterCondition> 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<ExifInfo, ExifInfo, QAfterFilterCondition> 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<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneStartsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.startsWith(
|
||||
property: r'timeZone',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneEndsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.endsWith(
|
||||
property: r'timeZone',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneContains(
|
||||
String value,
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.contains(
|
||||
property: r'timeZone',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneMatches(
|
||||
String pattern,
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.matches(
|
||||
property: r'timeZone',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'timeZone',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> 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<ExifInfo, ExifInfo, QSortBy> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDateTimeOriginal() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'dateTimeOriginal', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDateTimeOriginalDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'dateTimeOriginal', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDescription() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'description', Sort.asc);
|
||||
|
@ -2132,6 +2388,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
|
|||
return query.addSortBy(r'state', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByTimeZone() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'timeZone', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByTimeZoneDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'timeZone', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension ExifInfoQuerySortThenBy
|
||||
|
@ -2160,6 +2428,18 @@ extension ExifInfoQuerySortThenBy
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDateTimeOriginal() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'dateTimeOriginal', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDateTimeOriginalDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'dateTimeOriginal', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> 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<ExifInfo, ExifInfo, QAfterSortBy> thenByTimeZone() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'timeZone', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByTimeZoneDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'timeZone', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension ExifInfoQueryWhereDistinct
|
||||
|
@ -2333,6 +2625,12 @@ extension ExifInfoQueryWhereDistinct
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByDateTimeOriginal() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'dateTimeOriginal');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByDescription(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
|
@ -2409,6 +2707,13 @@ extension ExifInfoQueryWhereDistinct
|
|||
return query.addDistinctBy(r'state', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, ExifInfo, QDistinct> 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<ExifInfo, DateTime?, QQueryOperations>
|
||||
dateTimeOriginalProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'dateTimeOriginal');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, String?, QQueryOperations> descriptionProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'description');
|
||||
|
@ -2502,4 +2814,10 @@ extension ExifInfoQueryProperty
|
|||
return query.addPropertyName(r'state');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<ExifInfo, String?, QQueryOperations> timeZoneProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'timeZone');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
6
mobile/openapi/doc/ActivityApi.md
generated
6
mobile/openapi/doc/ActivityApi.md
generated
|
@ -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<ActivityResponseDto> getActivities(albumId, assetId, type)
|
||||
> List<ActivityResponseDto> 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
|
||||
|
||||
|
|
13
mobile/openapi/lib/api/activity_api.dart
generated
13
mobile/openapi/lib/api/activity_api.dart
generated
|
@ -111,7 +111,9 @@ class ActivityApi {
|
|||
/// * [String] assetId:
|
||||
///
|
||||
/// * [ReactionType] type:
|
||||
Future<Response> getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, }) async {
|
||||
///
|
||||
/// * [String] userId:
|
||||
Future<Response> 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 = <String>[];
|
||||
|
||||
|
@ -151,8 +156,10 @@ class ActivityApi {
|
|||
/// * [String] assetId:
|
||||
///
|
||||
/// * [ReactionType] type:
|
||||
Future<List<ActivityResponseDto>?> getActivities(String albumId, { String? assetId, ReactionType? type, }) async {
|
||||
final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, );
|
||||
///
|
||||
/// * [String] userId:
|
||||
Future<List<ActivityResponseDto>?> 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));
|
||||
}
|
||||
|
|
2
mobile/openapi/test/activity_api_test.dart
generated
2
mobile/openapi/test/activity_api_test.dart
generated
|
@ -27,7 +27,7 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
//Future<List<ActivityResponseDto>> getActivities(String albumId, { String assetId, ReactionType type }) async
|
||||
//Future<List<ActivityResponseDto>> getActivities(String albumId, { String assetId, ReactionType type, String userId }) async
|
||||
test('test getActivities', () async {
|
||||
// TODO
|
||||
});
|
||||
|
|
|
@ -1401,7 +1401,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.1.3"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": [],
|
||||
|
|
4
server/package-lock.json
generated
4
server/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "immich",
|
||||
"version": "1.83.0",
|
||||
"version": "1.84.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -28,6 +28,7 @@ export class ActivityService {
|
|||
async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
|
||||
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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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, {
|
||||
|
|
2
web/src/api/open-api/base.ts
generated
2
web/src/api/open-api/base.ts
generated
|
@ -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).
|
||||
|
|
2
web/src/api/open-api/common.ts
generated
2
web/src/api/open-api/common.ts
generated
|
@ -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).
|
||||
|
|
2
web/src/api/open-api/configuration.ts
generated
2
web/src/api/open-api/configuration.ts
generated
|
@ -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).
|
||||
|
|
2
web/src/api/open-api/index.ts
generated
2
web/src/api/open-api/index.ts
generated
|
@ -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).
|
||||
|
|
|
@ -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 @@
|
|||
/>
|
||||
<MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" />
|
||||
|
||||
{#if hasStackChildern}
|
||||
{#if hasStackChildren}
|
||||
<MenuOption on:click={() => onMenuClick('unstack')} text="Un-Stack" />
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -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 @@
|
|||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-y-hidden bg-black"
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
<div class="z-[1000] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
{#if isSlideshowMode}
|
||||
<!-- SlideShowController -->
|
||||
<div class="flex">
|
||||
<div class="m-4 flex gap-2">
|
||||
<CircleIconButton icon={mdiClose} on:click={handleStopSlideshow} title="Exit Slideshow" />
|
||||
<CircleIconButton
|
||||
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
|
||||
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
|
||||
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
|
||||
/>
|
||||
<CircleIconButton icon={mdiChevronLeft} on:click={navigateAssetBackward} title="Previous" />
|
||||
<CircleIconButton icon={mdiChevronRight} on:click={navigateAssetForward} title="Next" />
|
||||
</div>
|
||||
<ProgressBar
|
||||
autoplay
|
||||
bind:this={progressBar}
|
||||
bind:status={progressBarStatus}
|
||||
on:done={navigateAssetForward}
|
||||
duration={5000}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None}
|
||||
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
isMotionPhotoPlaying={shouldPlayMotionPhoto}
|
||||
|
@ -531,7 +575,7 @@
|
|||
showDownloadButton={shouldShowDownloadButton}
|
||||
showDetailButton={shouldShowDetailButton}
|
||||
showSlideshow={!!assetStore}
|
||||
hasStackChildern={$stackAssetsStore.length > 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}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isSlideshowMode && showNavigation}
|
||||
<div class="column-span-1 z-[999] col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation}
|
||||
<div class="z-[1001] column-span-1 col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
|
||||
<NavigationArea on:click={navigateAssetBackward}><Icon path={mdiChevronLeft} size="36" /></NavigationArea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div class="relative col-span-4 col-start-1 row-span-full row-start-1">
|
||||
<div class="z-[1000] relative col-start-1 col-span-4 row-start-1 row-span-full" bind:this={assetViewerHtmlElement}>
|
||||
{#if $slideshowState != SlideshowState.None}
|
||||
<div class="z-[1000] absolute w-full flex">
|
||||
<SlideshowBar
|
||||
on:prev={navigateAssetBackward}
|
||||
on:next={navigateAssetForward}
|
||||
on:close={() => ($slideshowState = SlideshowState.StopSlideshow)}
|
||||
/>
|
||||
</div>
|
||||
{/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}
|
||||
<div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
|
||||
<div
|
||||
class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60"
|
||||
|
@ -664,19 +719,17 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stack & Stack Controller -->
|
||||
|
||||
{#if !isSlideshowMode && showNavigation}
|
||||
<div class="z-[999] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation}
|
||||
<div class="z-[1001] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
|
||||
<NavigationArea on:click={navigateAssetForward}><Icon path={mdiChevronRight} size="36" /></NavigationArea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isSlideshowMode && $isShowDetail}
|
||||
{#if $slideshowState === SlideshowState.None && $isShowDetail}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="z-[1002] row-start-1 row-span-5 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
|
||||
class="z-[1002] row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel
|
||||
|
|
78
web/src/lib/components/asset-viewer/slideshow-bar.svelte
Normal file
78
web/src/lib/components/asset-viewer/slideshow-bar.svelte
Normal file
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
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();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="m-4 flex gap-2">
|
||||
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} title="Exit Slideshow" />
|
||||
{#if $slideshowShuffle}
|
||||
<CircleIconButton icon={mdiShuffle} on:click={() => ($slideshowShuffle = false)} title="Shuffle" />
|
||||
{:else}
|
||||
<CircleIconButton icon={mdiShuffleDisabled} on:click={() => ($slideshowShuffle = true)} title="No shuffle" />
|
||||
{/if}
|
||||
<CircleIconButton
|
||||
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
|
||||
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
|
||||
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
|
||||
/>
|
||||
<CircleIconButton icon={mdiChevronLeft} on:click={() => dispatch('prev')} title="Previous" />
|
||||
<CircleIconButton icon={mdiChevronRight} on:click={() => dispatch('next')} title="Next" />
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
autoplay
|
||||
bind:this={progressBar}
|
||||
bind:status={progressBarStatus}
|
||||
on:done={() => dispatch('next')}
|
||||
duration={5000}
|
||||
/>
|
|
@ -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';
|
||||
</script>
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={windowHeight} />
|
||||
|
@ -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}
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
|
@ -105,10 +139,10 @@
|
|||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
on:mousemove={() => (hoverLabel = label)}
|
||||
>
|
||||
{#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year}
|
||||
{#if lastGroupYear !== year && (index == 0 || prevYearSegmentHeight(segments, index - 1) > 16)}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg"
|
||||
class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg font-mono"
|
||||
>
|
||||
{year}
|
||||
</div>
|
||||
|
|
|
@ -304,7 +304,20 @@ export class AssetStore {
|
|||
return this.assetToBucket[assetId]?.bucketIndex ?? null;
|
||||
}
|
||||
|
||||
updateAsset(_asset: AssetResponseDto) {
|
||||
async getRandomAsset(): Promise<AssetResponseDto | null> {
|
||||
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[]) {
|
||||
|
|
45
web/src/lib/stores/slideshow.store.ts
Normal file
45
web/src/lib/stores/slideshow.store.ts
Normal file
|
@ -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<boolean>(false);
|
||||
const stopState = writable<boolean>(false);
|
||||
|
||||
const slideshowShuffle = persisted<boolean>('slideshow-shuffle', true);
|
||||
const slideshowState = writable<SlideshowState>(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();
|
40
web/src/lib/utils/slideshow-history.ts
Normal file
40
web/src/lib/utils/slideshow-history.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 @@
|
|||
<CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
|
||||
{#if viewMode === ViewMode.ALBUM_OPTIONS}
|
||||
<ContextMenu {...contextMenuPosition}>
|
||||
{#if album.assetCount !== 0}
|
||||
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
|
||||
{/if}
|
||||
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
|
|
|
@ -87,7 +87,7 @@
|
|||
</div>
|
||||
|
||||
<AssetGrid forceDelete {assetStore} {assetInteractionStore}>
|
||||
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 py-4">
|
||||
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
|
||||
Trashed items will be permanently deleted after {$serverConfig.trashDays} days.
|
||||
</p>
|
||||
<EmptyPlaceholder
|
||||
|
|
Loading…
Reference in a new issue