Compare commits

..

8 Commits

Author SHA1 Message Date
Alex Tran
b49d9660f6 reduce widget rebuild 2023-11-02 21:33:33 -05:00
Jason Rasmussen
b58edae134 fix(web): timeline alignment (#4808) 2023-11-02 15:11:59 -05:00
martin
2b9f20a1b5 fix: update like status (#4803) 2023-11-02 14:43:27 -04:00
Alex
d5f8199655 fix(web): scrollbar not showing year (#4782)
* fix(web): scrollbar not showing year

* grammar

* fix test
2023-11-01 20:50:24 -05:00
Alex
d8903de92e docs: remove read-only related content (#4781)
* docs: remove read-only related content

* format

* broken link
2023-11-01 20:49:57 -05:00
Jason Rasmussen
1d35965d03 feat(web): shuffle slideshow order (#4277)
* feat(web): shuffle slideshow order

* Fix play/stop issues

* Enter/exit fullscreen mode
* Prevent navigation to the next asset after exiting slideshow mode

* Fix entering the slideshow mode from an album page

* Simplify markup of the AssetViewer

Group viewer area and navigation (prev/next/slideshow bar) controls together

* Select a random asset from a random bucket

* Preserve assets order in random mode

* Exit fullscreen mode only if it is active

* Extract SlideshowHistory class

* Use traditional functions instead of arrow functions

* Refactor SlideshowHistory class

* Extract SlideshowBar component

* Fix comments

* Hide Say something in slideshow mode

---------

Co-authored-by: brighteyed <sergey.kondrikov@gmail.com>
2023-11-01 21:34:30 -04:00
Alex
309bf1ad22 chore: post release tasks 2023-11-01 14:43:10 -05:00
Jason Rasmussen
0130591a0f fix: show/set activity like per user (#4775)
* fix: like per user

* chore: open api

* chore: e2e test for userId filtering
2023-11-01 11:49:12 -04:00
31 changed files with 733 additions and 352 deletions

View File

@@ -5076,10 +5076,11 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat
* @param {string} albumId
* @param {string} [assetId]
* @param {ReactionType} [type]
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivities: async (albumId: string, assetId?: string, type?: ReactionType, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getActivities: async (albumId: string, assetId?: string, type?: ReactionType, userId?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'albumId' is not null or undefined
assertParamExists('getActivities', 'albumId', albumId)
const localVarPath = `/activity`;
@@ -5115,6 +5116,10 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat
localVarQueryParameter['type'] = type;
}
if (userId !== undefined) {
localVarQueryParameter['userId'] = userId;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -5211,11 +5216,12 @@ export const ActivityApiFp = function(configuration?: Configuration) {
* @param {string} albumId
* @param {string} [assetId]
* @param {ReactionType} [type]
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getActivities(albumId: string, assetId?: string, type?: ReactionType, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ActivityResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, options);
async getActivities(albumId: string, assetId?: string, type?: ReactionType, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ActivityResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -5264,7 +5270,7 @@ export const ActivityApiFactory = function (configuration?: Configuration, baseP
* @throws {RequiredError}
*/
getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<ActivityResponseDto>> {
return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(axios, basePath));
return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(axios, basePath));
},
/**
*
@@ -5332,6 +5338,13 @@ export interface ActivityApiGetActivitiesRequest {
* @memberof ActivityApiGetActivities
*/
readonly type?: ReactionType
/**
*
* @type {string}
* @memberof ActivityApiGetActivities
*/
readonly userId?: string
}
/**
@@ -5392,7 +5405,7 @@ export class ActivityApi extends BaseAPI {
* @memberof ActivityApi
*/
public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(this.axios, this.basePath));
return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@@ -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.

View File

@@ -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
```
:::

View File

@@ -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.

View File

@@ -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>

View File

@@ -28,6 +28,12 @@ PODS:
- Flutter
- isar_flutter_libs (1.0.0):
- Flutter
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- media_kit_video (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -42,6 +48,8 @@ PODS:
- FlutterMacOS
- ReachabilitySwift (5.0.0)
- SAMKeychain (1.5.3)
- screen_brightness_ios (0.1.0):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -55,6 +63,8 @@ PODS:
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- volume_controller (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
@@ -71,16 +81,21 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
@@ -115,6 +130,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/integration_test/ios"
isar_flutter_libs:
:path: ".symlinks/plugins/isar_flutter_libs/ios"
media_kit_libs_ios_video:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_native_event_loop:
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
media_kit_video:
:path: ".symlinks/plugins/media_kit_video/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -125,6 +146,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -135,6 +158,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios"
volume_controller:
:path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
@@ -152,6 +177,9 @@ SPEC CHECKSUMS:
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
integration_test: 13825b8a9334a850581300559b8839134b124670
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
@@ -159,14 +187,16 @@ SPEC CHECKSUMS:
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.12.1
COCOAPODS: 1.11.3

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -35,6 +35,7 @@ import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
void main() async {
@@ -49,6 +50,7 @@ void main() async {
Future<void> initApp() async {
await EasyLocalization.ensureInitialized();
MediaKit.ensureInitialized();
if (kReleaseMode && Platform.isAndroid) {
try {

View File

@@ -791,11 +791,6 @@ class GalleryViewerPage extends HookConsumerWidget {
localPosition = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(
tag: isFromDto
? '${a.remoteId}-$heroOffset'
: a.id + heroOffset,
),
filterQuality: FilterQuality.high,
maxScale: 1.0,
minScale: 1.0,

View File

@@ -9,6 +9,8 @@ import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -125,99 +127,128 @@ class VideoPlayer extends StatefulWidget {
}
class _VideoPlayerState extends State<VideoPlayer> {
late VideoPlayerController videoPlayerController;
ChewieController? chewieController;
// late VideoPlayerController videoPlayerController;
// ChewieController? chewieController;
// Create a [Player] to control playback.
late final player = Player();
// Create a [VideoController] to handle video output from [Player].
late final controller = VideoController(player);
@override
void initState() {
super.initState();
initializePlayer();
// initializePlayer();
videoPlayerController.addListener(() {
if (videoPlayerController.value.isInitialized) {
if (videoPlayerController.value.isPlaying) {
WakelockPlus.enable();
widget.onPlaying?.call();
} else if (!videoPlayerController.value.isPlaying) {
WakelockPlus.disable();
widget.onPaused?.call();
}
// videoPlayerController.addListener(() {
// if (videoPlayerController.value.isInitialized) {
// if (videoPlayerController.value.isPlaying) {
// WakelockPlus.enable();
// widget.onPlaying?.call();
// } else if (!videoPlayerController.value.isPlaying) {
// WakelockPlus.disable();
// widget.onPaused?.call();
// }
if (videoPlayerController.value.position ==
videoPlayerController.value.duration) {
WakelockPlus.disable();
widget.onVideoEnded();
}
}
});
}
// if (videoPlayerController.value.position ==
// videoPlayerController.value.duration) {
// WakelockPlus.disable();
// widget.onVideoEnded();
// }
// }
// });
Future<void> initializePlayer() async {
try {
videoPlayerController = widget.file == null
? VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
)
: VideoPlayerController.file(widget.file!);
await videoPlayerController.initialize();
_createChewieController();
setState(() {});
} catch (e) {
debugPrint("ERROR initialize video player $e");
if (widget.file == null) {
player.open(
Media(
Uri.parse(widget.url!).toString(),
httpHeaders: {
"Authorization": "Bearer ${widget.jwtToken}",
},
),
);
} else {
player.open(
Media(
widget.file!.path,
),
);
}
}
_createChewieController() {
chewieController = ChewieController(
controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100,
),
showOptions: true,
showControlsOnInitialize: false,
videoPlayerController: videoPlayerController,
autoPlay: true,
autoInitialize: true,
allowFullScreen: false,
allowedScreenSleep: false,
showControls: !widget.isMotionVideo,
customControls: const VideoPlayerControls(),
hideControlsTimer: const Duration(seconds: 5),
);
}
// Future<void> initializePlayer() async {
// try {
// videoPlayerController = widget.file == null
// ? VideoPlayerController.networkUrl(
// Uri.parse(widget.url!),
// httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
// )
// : VideoPlayerController.file(widget.file!);
// await videoPlayerController.initialize();
// _createChewieController();
// setState(() {});
// } catch (e) {
// debugPrint("ERROR initialize video player $e");
// }
// }
// _createChewieController() {
// chewieController = ChewieController(
// controlsSafeAreaMinimum: const EdgeInsets.only(
// bottom: 100,
// ),
// showOptions: true,
// showControlsOnInitialize: false,
// videoPlayerController: videoPlayerController,
// autoPlay: true,
// autoInitialize: true,
// allowFullScreen: false,
// allowedScreenSleep: false,
// showControls: !widget.isMotionVideo,
// customControls: const VideoPlayerControls(),
// hideControlsTimer: const Duration(seconds: 5),
// );
// }
@override
void dispose() {
super.dispose();
videoPlayerController.pause();
videoPlayerController.dispose();
chewieController?.dispose();
player.dispose();
// videoPlayerController.pause();
// videoPlayerController.dispose();
// chewieController?.dispose();
}
@override
Widget build(BuildContext context) {
if (chewieController?.videoPlayerController.value.isInitialized == true) {
return SizedBox(
child: Chewie(
controller: chewieController!,
),
);
} else {
return SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
child: Center(
child: Stack(
children: [
if (widget.placeholder != null) widget.placeholder!,
const Center(
child: ImmichLoadingIndicator(),
),
],
),
),
);
}
return SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: Video(controller: controller),
);
// if (chewieController?.videoPlayerController.value.isInitialized == true) {
// return SizedBox(
// child: Chewie(
// controller: chewieController!,
// ),
// );
// } else {
// return SizedBox(
// height: MediaQuery.of(context).size.height,
// width: MediaQuery.of(context).size.width,
// child: Center(
// child: Stack(
// children: [
// if (widget.placeholder != null) widget.placeholder!,
// const Center(
// child: ImmichLoadingIndicator(),
// ),
// ],
// ),
// ),
// );
// }
}
}

View File

@@ -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

View File

@@ -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));
}

View File

@@ -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
});

View File

@@ -21,10 +21,10 @@ packages:
dependency: transitive
description:
name: archive
sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
sha256: "7e0d52067d05f2e0324268097ba723b71cb41ac8a6a2b24d1edf9c536b987b03"
url: "https://pub.dev"
source: hosted
version: "3.3.7"
version: "3.4.6"
args:
dependency: transitive
description:
@@ -201,6 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.7.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7
url: "https://pub.dev"
source: hosted
version: "0.4.0"
clock:
dependency: transitive
description:
@@ -455,10 +463,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "559c600f056e7c704bd843723c21e01b5fba47e8824bd02422165bcc02a5de1d"
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.9.3"
version: "0.13.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -516,10 +524,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: "6777a3abb974021a39b5fdd2d46a03ca390e03903b6351f21d10e7ecc969f12d"
sha256: d93394f22f73e810bda59e11ebe83329c5511d6460b6b7509c4e1f3c92d6d625
url: "https://pub.dev"
source: hosted
version: "2.2.16"
version: "2.3.5"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -691,10 +699,10 @@ packages:
dependency: transitive
description:
name: image
sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6"
sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271"
url: "https://pub.dev"
source: hosted
version: "3.3.0"
version: "4.1.3"
image_picker:
dependency: "direct main"
description:
@@ -868,6 +876,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.0"
media_kit:
dependency: "direct main"
description:
name: media_kit
sha256: "3289062540e3b8b9746e5c50d95bd78a9289826b7227e253dff806d002b9e67a"
url: "https://pub.dev"
source: hosted
version: "1.1.10+1"
media_kit_libs_android_video:
dependency: transitive
description:
name: media_kit_libs_android_video
sha256: "9dd8012572e4aff47516e55f2597998f0a378e3d588d0fad0ca1f11a53ae090c"
url: "https://pub.dev"
source: hosted
version: "1.3.6"
media_kit_libs_ios_video:
dependency: transitive
description:
name: media_kit_libs_ios_video
sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991
url: "https://pub.dev"
source: hosted
version: "1.1.4"
media_kit_libs_linux:
dependency: transitive
description:
name: media_kit_libs_linux
sha256: e186891c31daa6bedab4d74dcdb4e8adfccc7d786bfed6ad81fe24a3b3010310
url: "https://pub.dev"
source: hosted
version: "1.1.3"
media_kit_libs_macos_video:
dependency: transitive
description:
name: media_kit_libs_macos_video
sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d
url: "https://pub.dev"
source: hosted
version: "1.1.4"
media_kit_libs_video:
dependency: "direct main"
description:
name: media_kit_libs_video
sha256: "3688e0c31482074578652bf038ce6301a5d21e1eda6b54fc3117ffeb4bdba067"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
media_kit_libs_windows_video:
dependency: transitive
description:
name: media_kit_libs_windows_video
sha256: "7bace5f35d9afcc7f9b5cdadb7541d2191a66bb3fc71bfa11c1395b3360f6122"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
media_kit_native_event_loop:
dependency: transitive
description:
name: media_kit_native_event_loop
sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e
url: "https://pub.dev"
source: hosted
version: "1.0.8"
media_kit_video:
dependency: "direct main"
description:
name: media_kit_video
sha256: c048d11a19e379aebbe810647636e3fc6d18374637e2ae12def4ff8a4b99a882
url: "https://pub.dev"
source: hosted
version: "1.2.4"
meta:
dependency: transitive
description:
@@ -1171,6 +1251,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.27.7"
safe_local_storage:
dependency: transitive
description:
name: safe_local_storage
sha256: ede4eb6cb7d88a116b3d3bf1df70790b9e2038bc37cb19112e381217c74d9440
url: "https://pub.dev"
source: hosted
version: "1.0.2"
screen_brightness:
dependency: transitive
description:
name: screen_brightness
sha256: ed8da4a4511e79422fc1aa88138e920e4008cd312b72cdaa15ccb426c0faaedd
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
screen_brightness_android:
dependency: transitive
description:
name: screen_brightness_android
sha256: "3df10961e3a9e968a5e076fe27e7f4741fa8a1d3950bdeb48cf121ed529d0caf"
url: "https://pub.dev"
source: hosted
version: "0.1.0+2"
screen_brightness_ios:
dependency: transitive
description:
name: screen_brightness_ios
sha256: "99adc3ca5490b8294284aad5fcc87f061ad685050e03cf45d3d018fe398fd9a2"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
screen_brightness_macos:
dependency: transitive
description:
name: screen_brightness_macos
sha256: "64b34e7e3f4900d7687c8e8fb514246845a73ecec05ab53483ed025bd4a899fd"
url: "https://pub.dev"
source: hosted
version: "0.1.0+1"
screen_brightness_platform_interface:
dependency: transitive
description:
name: screen_brightness_platform_interface
sha256: b211d07f0c96637a15fb06f6168617e18030d5d74ad03795dd8547a52717c171
url: "https://pub.dev"
source: hosted
version: "0.1.0"
screen_brightness_windows:
dependency: transitive
description:
name: screen_brightness_windows
sha256: "9261bf33d0fc2707d8cf16339ce25768100a65e70af0fcabaf032fc12408ba86"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
scrollable_positioned_list:
dependency: "direct main"
description:
@@ -1448,6 +1584,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.2"
universal_platform:
dependency: transitive
description:
name: universal_platform
sha256: d315be0f6641898b280ffa34e2ddb14f3d12b1a37882557869646e0cc363d0cc
url: "https://pub.dev"
source: hosted
version: "1.0.0+1"
uri_parser:
dependency: transitive
description:
name: uri_parser
sha256: "6543c9fd86d2862fac55d800a43e67c0dcd1a41677cb69c2f8edfe73bbcf1835"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
url_launcher:
dependency: "direct main"
description:
@@ -1576,6 +1728,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "11.7.1"
volume_controller:
dependency: transitive
description:
name: volume_controller
sha256: "189bdc7a554f476b412e4c8b2f474562b09d74bc458c23667356bce3ca1d48c9"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
wakelock_plus:
dependency: "direct main"
description:

View File

@@ -53,6 +53,10 @@ dependencies:
wakelock_plus: ^1.1.1
flutter_local_notifications: ^15.1.0+1
media_kit: ^1.1.10 # Primary package.
media_kit_video: ^1.2.4 # For video rendering.
media_kit_libs_video: ^1.0.4 # Native video dependencies.
openapi:
path: openapi
@@ -75,7 +79,7 @@ dev_dependencies:
flutter_lints: ^2.0.1
build_runner: ^2.2.1
auto_route_generator: ^5.0.2
flutter_launcher_icons: "^0.9.2"
flutter_launcher_icons: "^0.13.1"
flutter_native_splash: ^2.2.16
isar_generator: *isar_version
mockito: ^5.3.2

View File

@@ -30,6 +30,15 @@
"schema": {
"$ref": "#/components/schemas/ReactionType"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {

View File

@@ -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';

View File

@@ -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,

View File

@@ -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, {

View File

@@ -5076,10 +5076,11 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat
* @param {string} albumId
* @param {string} [assetId]
* @param {ReactionType} [type]
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivities: async (albumId: string, assetId?: string, type?: ReactionType, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getActivities: async (albumId: string, assetId?: string, type?: ReactionType, userId?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'albumId' is not null or undefined
assertParamExists('getActivities', 'albumId', albumId)
const localVarPath = `/activity`;
@@ -5115,6 +5116,10 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat
localVarQueryParameter['type'] = type;
}
if (userId !== undefined) {
localVarQueryParameter['userId'] = userId;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -5211,11 +5216,12 @@ export const ActivityApiFp = function(configuration?: Configuration) {
* @param {string} albumId
* @param {string} [assetId]
* @param {ReactionType} [type]
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getActivities(albumId: string, assetId?: string, type?: ReactionType, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ActivityResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, options);
async getActivities(albumId: string, assetId?: string, type?: ReactionType, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ActivityResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -5264,7 +5270,7 @@ export const ActivityApiFactory = function (configuration?: Configuration, baseP
* @throws {RequiredError}
*/
getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<ActivityResponseDto>> {
return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(axios, basePath));
return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(axios, basePath));
},
/**
*
@@ -5332,6 +5338,13 @@ export interface ActivityApiGetActivitiesRequest {
* @memberof ActivityApiGetActivities
*/
readonly type?: ReactionType
/**
*
* @type {string}
* @memberof ActivityApiGetActivities
*/
readonly userId?: string
}
/**
@@ -5392,7 +5405,7 @@ export class ActivityApi extends BaseAPI {
* @memberof ActivityApi
*/
public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(this.axios, this.basePath));
return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@@ -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;
}
};
@@ -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}
@@ -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

View 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}
/>

View File

@@ -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>

View File

@@ -96,6 +96,19 @@
{@const date = fromLocalDateTime(segment.timeGroup)}
{@const year = date.year}
{@const label = `${date.toLocaleString({ month: 'short' })} ${year}`}
{@const lastGroupYear = fromLocalDateTime(segments[index - 1]?.timeGroup).year}
<!-- Check if the next three segments are different years then don't render
to avoid overlapse -->
{@const canRenderYear = segments.slice(index + 1, index + 3).reduce((_, curr) => {
const nextGroupYear = fromLocalDateTime(curr.timeGroup).year;
if (nextGroupYear !== year || curr.height < 1) {
return false;
}
return true;
}, true)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
@@ -105,10 +118,10 @@
aria-label={segment.timeGroup + ' ' + segment.count}
on:mousemove={() => (hoverLabel = label)}
>
{#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year}
{#if lastGroupYear !== year && canRenderYear}
<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>

View File

@@ -304,6 +304,19 @@ export class AssetStore {
return this.assetToBucket[assetId]?.bucketIndex ?? null;
}
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) {
const asset = this.assets.find((asset) => asset.id === _asset.id);
if (!asset) {

View 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();

View 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;
}
}

View File

@@ -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}

View File

@@ -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