Compare commits

..

1 Commits

Author SHA1 Message Date
mertalev
b7e4e55489 wip 2025-09-25 14:17:11 -04:00
83 changed files with 2362 additions and 2303 deletions

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
mobile:
@@ -73,7 +73,7 @@ jobs:
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
with:
path: |
~/.gradle/caches
@@ -130,7 +130,7 @@ jobs:
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
if: github.ref == 'refs/heads/main'
with:
path: |

View File

@@ -24,7 +24,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
server:

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
docs:

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -58,7 +58,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -49,7 +49,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -111,7 +111,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
mobile:

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
i18n:

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
i18n:

View File

@@ -140,7 +140,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
env_file:
- .env
environment:

View File

@@ -63,7 +63,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
env_file:
- .env
environment:

View File

@@ -56,7 +56,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}

View File

@@ -28,7 +28,6 @@
"add_to_album": "Add to album",
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
"add_to_album_bottom_sheet_some_local_assets": "Some local assets could not be added to album",
"add_to_album_toggle": "Toggle selection for {album}",
"add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})",

View File

@@ -116,7 +116,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (Platform.isAndroid) {
await _backgroundHostApi.showNotification(
IntlKeys.uploading_media.t(),
IntlKeys.backup_background_service_default_notification.t(),
IntlKeys.backup_background_service_in_progress_notification.t(),
);
}

View File

@@ -21,7 +21,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
}
extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
LocalAsset toDto({String? remoteId}) => LocalAsset(
LocalAsset toDto() => LocalAsset(
id: id,
name: name,
checksum: checksum,
@@ -32,7 +32,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
isFavorite: isFavorite,
height: height,
width: width,
remoteId: remoteId,
remoteId: null,
orientation: orientation,
);
}

View File

@@ -49,7 +49,7 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
}
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
RemoteAsset toDto({String? localId}) => RemoteAsset(
RemoteAsset toDto() => RemoteAsset(
id: id,
name: name,
ownerId: ownerId,
@@ -64,7 +64,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
thumbHash: thumbHash,
visibility: visibility,
livePhotoVideoId: livePhotoVideoId,
localId: localId,
localId: null,
stackId: stackId,
);
}

View File

@@ -148,9 +148,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..limit(count, offset: offset);
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto(remoteId: row.read(_db.remoteAssetEntity.id)))
.get();
return query.map((row) {
final asset = row.readTable(_db.localAssetEntity).toDto();
return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id));
}).get();
}
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
@@ -164,15 +165,17 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.count(where: (row) => row.albumId.equals(albumId))
.map(_generateBuckets)
.watch()
.map((results) => results.isNotEmpty ? results.first : const <Bucket>[])
.handleError((error) => const <Bucket>[]);
.map((results) => results.isNotEmpty ? results.first : <Bucket>[])
.handleError((error) {
return [];
});
}
return (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId)))
.watch()
.switchMap((albums) {
if (albums.isEmpty) {
return Stream.value(const <Bucket>[]);
return Stream.value(<Bucket>[]);
}
final album = albums.first;
@@ -204,8 +207,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
})
// If there's an error (e.g., album was deleted), return empty buckets
.handleError((error) => const <Bucket>[]);
.handleError((error) {
// If there's an error (e.g., album was deleted), return empty buckets
return <Bucket>[];
});
}
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async {
@@ -213,22 +218,17 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
// If album doesn't exist (was deleted), return empty list
if (albumData == null) {
return const <BaseAsset>[];
return <BaseAsset>[];
}
final isAscending = albumData.order == AlbumAssetOrder.asc;
final query = _db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([
final query = _db.remoteAssetEntity.select().join([
innerJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
if (isAscending) {
@@ -239,14 +239,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.limit(count, offset: offset);
return query
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
.get();
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
TimelineQuery fromAssets(List<BaseAsset> assets) => (
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
assetSource: (offset, count) => Future.value(assets.skip(offset).take(count).toList(growable: false)),
assetSource: (offset, count) => Future.value(assets.skip(offset).take(count).toList()),
);
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
@@ -488,7 +486,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
@pragma('vm:prefer-inline')
TimelineQuery _remoteQueryBuilder({
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
GroupAssetsBy groupBy = GroupAssetsBy.day,
@@ -526,7 +523,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch();
}
@pragma('vm:prefer-inline')
Future<List<BaseAsset>> _getRemoteAssets({
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
required int offset,
@@ -547,9 +543,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);
return query
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
.get();
return query.map((row) {
final asset = row.readTable(_db.remoteAssetEntity).toDto();
final localId = row.read(_db.localAssetEntity.id);
return asset.copyWith(localId: localId);
}).get();
} else {
final query = _db.remoteAssetEntity.select()
..where(filter)
@@ -562,12 +560,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
List<Bucket> _generateBuckets(int count) {
final buckets = List.filled(
(count / kTimelineNoneSegmentSize).ceil(),
const Bucket(assetCount: kTimelineNoneSegmentSize),
final buckets = List.generate(
(count / kTimelineNoneSegmentSize).floor(),
(_) => const Bucket(assetCount: kTimelineNoneSegmentSize),
);
if (count % kTimelineNoneSegmentSize != 0) {
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize));
}
return buckets;
}
@@ -592,6 +590,10 @@ extension on String {
GroupAssetsBy.month => "y-M",
GroupAssetsBy.none => throw ArgumentError("GroupAssetsBy.none is not supported for date formatting"),
};
return DateFormat(format, 'en').parse(this);
try {
return DateFormat(format, 'en').parse(this);
} catch (e) {
throw FormatException("Invalid date format: $this", e);
}
}
}

View File

@@ -25,10 +25,9 @@ class DriftMapPage extends StatelessWidget {
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
style: IconButton.styleFrom(
shape: const CircleBorder(side: BorderSide(width: 1, color: Colors.black26)),
padding: const EdgeInsets.all(8),
backgroundColor: Colors.indigo,
shadowColor: Colors.black26,
elevation: 4,
backgroundColor: Colors.indigo.withValues(alpha: 0.7),
),
),
),

View File

@@ -36,7 +36,7 @@ class UnStackActionButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.layers_clear_outlined,
iconData: Icons.filter_none_rounded,
label: "unstack".t(context: context),
onPressed: () => _onTap(context, ref),
);

View File

@@ -51,7 +51,6 @@ class AssetDetailBottomSheet extends ConsumerWidget {
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView,
isStacked: asset.hasRemote && (asset as RemoteAsset).stackId != null,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,

View File

@@ -57,7 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final actions = <Widget>[
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(

View File

@@ -13,7 +13,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -45,7 +44,6 @@ class ArchiveBottomSheet extends ConsumerWidget {
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@@ -13,7 +13,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -45,7 +44,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -20,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -64,19 +62,11 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return;
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
if (selectedAssets.length != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context),
);
}
if (addedCount != remoteAssets.length) {
if (addedCount != selectedAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
@@ -118,18 +108,15 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: multiselect.hasRemote
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: [],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
);
}
}

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
@@ -11,14 +10,13 @@ class MapBottomSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
return const BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.9,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
actions: [],
backgroundColor: context.themeData.colorScheme.surface,
slivers: [const SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
slivers: [SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
);
}
}

View File

@@ -17,7 +17,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -103,7 +102,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@@ -123,14 +123,28 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
return provider;
}
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
if (_shouldUseLocalAsset(asset)) {
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = kThumbnailResolution}) {
assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
if (remoteId != null) {
return RemoteThumbProvider(assetId: remoteId);
}
if (_shouldUseLocalAsset(asset!)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
}
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null;
final String assetId;
if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!;
} else if (asset is RemoteAsset) {
assetId = asset.id;
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
return RemoteThumbProvider(assetId: assetId);
}
bool _shouldUseLocalAsset(BaseAsset asset) =>

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -38,7 +39,14 @@ class Thumbnail extends StatefulWidget {
),
_ => null,
},
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size);
imageProvider = switch (asset) {
RemoteAsset() =>
asset.localId == null
? RemoteThumbProvider(assetId: asset.id)
: LocalThumbProvider(id: asset.localId!, size: size, assetType: asset.type),
LocalAsset() => LocalThumbProvider(id: asset.id, size: size, assetType: asset.type),
_ => null,
};
@override
State<Thumbnail> createState() => _ThumbnailState();

View File

@@ -54,6 +54,8 @@ class ThumbnailTile extends ConsumerWidget {
)
: const BoxDecoration();
final hasStack = asset is RemoteAsset && asset.stackId != null;
final bool storageIndicator =
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
@@ -75,10 +77,21 @@ class ThumbnailTile extends ConsumerWidget {
child: Thumbnail.fromAsset(asset: asset, size: size),
),
),
if (asset != null)
if (hasStack)
Align(
alignment: Alignment.topRight,
child: _AssetTypeIcons(asset: asset),
child: Padding(
padding: EdgeInsets.only(right: 10.0, top: asset.isVideo ? 24.0 : 6.0),
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
),
),
if (asset != null && asset.isVideo)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.only(right: 10.0, top: 6.0),
child: _VideoIndicator(asset.duration),
),
),
if (storageIndicator && asset != null)
switch (asset.storage) {
@@ -201,34 +214,3 @@ class _TileOverlayIcon extends StatelessWidget {
);
}
}
class _AssetTypeIcons extends StatelessWidget {
final BaseAsset asset;
const _AssetTypeIcons({required this.asset});
@override
Widget build(BuildContext context) {
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (asset.isVideo)
Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)),
if (hasStack)
const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
),
if (isLivePhoto)
const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
),
],
);
}
}

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
@@ -10,8 +9,8 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -188,8 +187,6 @@ class _Map extends StatelessWidget {
styleString: style,
onMapCreated: onMapCreated,
onStyleLoadedCallback: onMapReady,
attributionButtonPosition: AttributionButtonPosition.topRight,
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
),
),
);

View File

@@ -212,14 +212,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
if (fallbackSegment != null) {
// Scroll to the segment with a small offset to show the header
final targetOffset = fallbackSegment.startOffset - 50;
ref.read(timelineStateProvider.notifier).setScrubbing(true);
_scrollController
.animateTo(
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
)
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
_scrollController.animateTo(
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
});
}

View File

@@ -1,5 +1,6 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:async';
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
@@ -11,8 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class EnqueueStatus {
final int enqueueCount;
@@ -89,6 +90,33 @@ class DriftUploadStatus {
networkSpeedAsString.hashCode ^
isFailed.hashCode;
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'taskId': taskId,
'filename': filename,
'progress': progress,
'fileSize': fileSize,
'networkSpeedAsString': networkSpeedAsString,
'isFailed': isFailed,
};
}
factory DriftUploadStatus.fromMap(Map<String, dynamic> map) {
return DriftUploadStatus(
taskId: map['taskId'] as String,
filename: map['filename'] as String,
progress: map['progress'] as double,
fileSize: map['fileSize'] as int,
networkSpeedAsString: map['networkSpeedAsString'] as String,
isFailed: map['isFailed'] != null ? map['isFailed'] as bool : null,
);
}
String toJson() => json.encode(toMap());
factory DriftUploadStatus.fromJson(String source) =>
DriftUploadStatus.fromMap(json.decode(source) as Map<String, dynamic>);
}
class DriftBackupState {
@@ -239,7 +267,6 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
state = state.copyWith(uploadItems: {...state.uploadItems, taskId: currentItem.copyWith(isFailed: true)});
_logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
break;
case TaskStatus.canceled:

View File

@@ -3,10 +3,7 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -39,7 +36,6 @@ class ActionNotifier extends Notifier<void> {
late ActionService _service;
late UploadService _uploadService;
late DownloadService _downloadService;
late AssetService _assetService;
ActionNotifier() : super();
@@ -47,7 +43,6 @@ class ActionNotifier extends Notifier<void> {
void build() {
_uploadService = ref.watch(uploadServiceProvider);
_service = ref.watch(actionServiceProvider);
_assetService = ref.watch(assetServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
@@ -340,14 +335,6 @@ class ActionNotifier extends Notifier<void> {
final assets = _getOwnedRemoteAssetsForSource(source);
try {
await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList());
if (source == ActionSource.viewer) {
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
if (updatedParent != null) {
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
}
}
return ActionResult(count: assets.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unstack assets', error, stack);

View File

@@ -28,8 +28,6 @@ class MultiSelectState {
bool get hasRemote =>
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);

View File

@@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
@@ -20,9 +19,9 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:immich_mobile/utils/debug_print.dart';
final uploadServiceProvider = Provider((ref) {
final service = UploadService(
@@ -206,20 +205,10 @@ class UploadService {
return _uploadRepository.start();
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
switch (update.status) {
case TaskStatus.complete:
_handleLivePhoto(update);
if (CurrentPlatform.isIOS) {
try {
final path = await update.task.filePath();
await File(path).delete();
} catch (e) {
_logger.severe('Error deleting file path for iOS: $e');
}
}
break;
default:

View File

@@ -16,7 +16,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
class ActionButtonContext {
@@ -25,7 +24,6 @@ class ActionButtonContext {
final bool isArchived;
final bool isTrashEnabled;
final bool isInLockedView;
final bool isStacked;
final RemoteAlbum? currentAlbum;
final bool advancedTroubleshooting;
final ActionSource source;
@@ -35,7 +33,6 @@ class ActionButtonContext {
required this.isOwner,
required this.isArchived,
required this.isTrashEnabled,
required this.isStacked,
required this.isInLockedView,
required this.currentAlbum,
required this.advancedTroubleshooting,
@@ -58,7 +55,6 @@ enum ActionButtonType {
deleteLocal,
upload,
removeFromAlbum,
unstack,
likeActivity;
bool shouldShow(ActionButtonContext context) {
@@ -114,10 +110,6 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.currentAlbum != null,
ActionButtonType.unstack =>
context.isOwner && //
!context.isInLockedView && //
context.isStacked,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
@@ -146,13 +138,28 @@ enum ActionButtonType {
source: context.source,
),
ActionButtonType.likeActivity => const LikeActivityActionButton(),
ActionButtonType.unstack => UnStackActionButton(source: context.source),
};
}
}
class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
static const List<ActionButtonType> _actionTypes = [
ActionButtonType.advancedInfo,
ActionButtonType.share,
ActionButtonType.shareLink,
ActionButtonType.likeActivity,
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.download,
ActionButtonType.trash,
ActionButtonType.deletePermanent,
ActionButtonType.delete,
ActionButtonType.moveToLockFolder,
ActionButtonType.removeFromLockFolder,
ActionButtonType.deleteLocal,
ActionButtonType.upload,
ActionButtonType.removeFromAlbum,
];
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();

View File

@@ -129,24 +129,19 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
title: Builder(
builder: (BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset(
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
height: 40,
),
),
const Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: Duration(seconds: 4),
message:
"The old timeline is deprecated and will be removed in a future release. Kindly switch to the new timeline under Advanced Settings.",
child: Padding(
padding: EdgeInsets.only(top: 3.0),
child: Icon(Icons.error_rounded, fill: 1, color: Colors.amber, size: 20),
),
Builder(
builder: (context) {
return Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset(
context.isDarkTheme
? 'assets/immich-logo-inline-dark.svg'
: 'assets/immich-logo-inline-light.svg',
height: 40,
),
);
},
),
],
);

View File

@@ -82,7 +82,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -113,7 +112,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -129,7 +127,6 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -148,7 +145,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -165,7 +161,6 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -182,7 +177,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -201,7 +195,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -218,7 +211,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -235,7 +227,6 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -252,7 +243,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -269,7 +259,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -288,7 +277,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -305,7 +293,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -322,7 +309,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -341,7 +327,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -358,7 +343,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -375,7 +359,6 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -394,7 +377,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -411,7 +393,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -430,7 +411,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -447,7 +427,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -466,7 +445,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -485,7 +463,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -504,7 +481,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -521,7 +497,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -537,7 +512,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -556,7 +530,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -575,7 +548,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -591,7 +563,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -610,7 +581,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -627,7 +597,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -644,7 +613,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -660,7 +628,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -678,7 +645,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: true,
isStacked: false,
source: ActionSource.timeline,
);
@@ -694,7 +660,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -703,59 +668,6 @@ void main() {
});
});
group('unstack button', () {
test('should show when owner, not locked, has remote, and is stacked', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: true,
source: ActionSource.timeline,
);
expect(ActionButtonType.unstack.shouldShow(context), isTrue);
});
test('should not show when not stacked', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unstack.shouldShow(context), isFalse);
});
test('should not show when not owner', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: false,
isArchived: true,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unstack.shouldShow(context), isFalse);
});
});
group('ActionButtonType.buildButton', () {
late BaseAsset asset;
late ActionButtonContext context;
@@ -770,7 +682,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
});
@@ -787,22 +698,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.unstack) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: true,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
@@ -826,7 +721,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -847,7 +741,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -866,7 +759,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -886,7 +778,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -900,7 +791,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);

View File

@@ -1,4 +1,21 @@
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS builder
ARG TUSD_RELEASE=v2.8.0
ARG TUSD_AMD64_SHA256=33aaa15e14c7b025772d28605c6e0733ec2e8c1d6b646fa6b83af0049022a9ce
ARG TUSD_ARM64_SHA256=07f78ee2fd552296c40f3a7a5a06325a06b53f1dd1ba996a60ecc52696906481
# TODO: move this to base image
RUN apt-get update && apt-get install -y curl && \
if [ $(arch) = "x86_64" ]; then \
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_amd64.deb" && \
echo "${TUSD_AMD64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
else \
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_arm64.deb" && \
echo "${TUSD_ARM64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
fi && \
dpkg -i /tmp/tusd.deb && \
rm -f /tmp/tusd.deb
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp
@@ -40,6 +57,7 @@ ENV NODE_ENV=production \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
COPY --from=builder /usr/bin/tusd /usr/bin/tusd
COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli

View File

@@ -1,6 +1,21 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS dev
ARG TUSD_RELEASE=v2.8.0
ARG TUSD_AMD64_SHA256=33aaa15e14c7b025772d28605c6e0733ec2e8c1d6b646fa6b83af0049022a9ce
ARG TUSD_ARM64_SHA256=07f78ee2fd552296c40f3a7a5a06325a06b53f1dd1ba996a60ecc52696906481
# TODO: move this to base image
RUN if [ $(arch) = "x86_64" ]; then \
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_amd64.deb" && \
echo "${TUSD_AMD64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
else \
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_arm64.deb" && \
echo "${TUSD_ARM64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
fi && \
dpkg -i /tmp/tusd.deb && \
rm -f /tmp/tusd.deb
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp

View File

@@ -28,7 +28,6 @@ import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter];
export const middleware = [
FileUploadInterceptor,
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
@@ -85,7 +84,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
@Module({
imports: [...imports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }],
providers: [...common, ...middleware, FileUploadInterceptor, { provide: IWorker, useValue: ImmichWorker.Api }],
})
export class ApiModule extends BaseModule {}

View File

@@ -16,6 +16,11 @@ export const ADDED_IN_PREFIX = 'This property was added in ';
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000;
export const UPLOAD_TUSD_SOCKET_PATH = '/tmp/immich-tusd.sock';
export const UPLOAD_CHUNK_DIRECTORY = '/tmp/immich-chunked-uploads';
export const UPLOAD_TUSD_CONNECT_TIMEOUT_MS = 1000;
export const UPLOAD_TUSD_CONNECT_BACKOFF_MS = 100;
export const UPLOAD_TUSD_CONNECT_RETRIES = 30;
export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
cube: 'cube',

View File

@@ -0,0 +1,82 @@
import { All, BadRequestException, Body, Controller, HttpException, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { Request, Response } from 'express';
import {
TusdHookRequestDto,
TusdHookRequestType,
TusdHookResponseDto,
TusdPreCreateEventDto,
TusdPreFinishEventDto,
} from 'src/dtos/upload.dto';
import { Permission } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetUploadService } from 'src/services/asset-upload.service';
import { AuthService } from 'src/services/auth.service';
@ApiTags('Upload')
@Controller('upload')
export class AssetUploadController {
constructor(
private authService: AuthService,
private uploadService: AssetUploadService,
private logger: LoggingRepository,
) {
logger.setContext(AssetUploadController.name);
}
// Proxies chunked upload requests to the tusd server.
// Auth is handled in the pre-create and pre-finish hooks from tusd.
@All('asset{/*all}')
handleChunks(@Req() request: Request, @Res() response: Response): Promise<unknown> {
return this.uploadService.proxyChunks(request, response);
}
// This controller handles webhooks from the tusd server.
// See https://tus.github.io/tusd/advanced-topics/hooks/ for more information.
@Post('hook')
async processHook(@Body() payload: TusdHookRequestDto): Promise<TusdHookResponseDto> {
const request = payload.Event.HTTPRequest;
const lowerCaseHeaders: Record<string, string> = {};
for (const [key, [value]] of Object.entries(request.Header)) {
lowerCaseHeaders[key.toLowerCase()] = value;
}
try {
const auth = await this.authService.authenticate({
headers: lowerCaseHeaders,
queryParams: {},
metadata: { adminRoute: false, sharedLinkRoute: false, permission: Permission.AssetUpload, uri: request.URI },
});
switch (payload.Type) {
case TusdHookRequestType.PreCreate: {
const dto = plainToInstance(TusdPreCreateEventDto, payload.Event);
const errors = validateSync(dto, { whitelist: true });
if (errors.length > 0) {
throw new BadRequestException('Invalid payload');
}
return await this.uploadService.handlePreCreate(auth, dto, lowerCaseHeaders);
}
case TusdHookRequestType.PreFinish: {
const dto = plainToInstance(TusdPreFinishEventDto, payload.Event);
const errors = validateSync(dto, { whitelist: true });
if (errors.length > 0) {
throw new BadRequestException('Invalid payload');
}
return await this.uploadService.handlePreFinish(auth, dto);
}
default: {
throw new Error(`Unhandled hook type: ${payload.Type}`);
}
}
} catch (error: any) {
if (error instanceof HttpException) {
return { RejectUpload: true, HTTPResponse: { StatusCode: error.getStatus(), Body: error.message } };
}
this.logger.error('Error processing upload hook', error);
return { RejectUpload: true, HTTPResponse: { StatusCode: 500 } };
}
}
}

View File

@@ -3,6 +3,7 @@ import { AlbumController } from 'src/controllers/album.controller';
import { ApiKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetUploadController } from 'src/controllers/asset-upload.controller';
import { AssetController } from 'src/controllers/asset.controller';
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
import { AuthController } from 'src/controllers/auth.controller';
@@ -40,6 +41,7 @@ export const controllers = [
AppController,
AssetController,
AssetMediaController,
AssetUploadController,
AuthController,
AuthAdminController,
DownloadController,

View File

@@ -0,0 +1,133 @@
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
import { AssetMediaCreateDto } from 'src/dtos/asset-media.dto';
export enum TusdHookRequestType {
PreCreate = 'pre-create',
PreFinish = 'pre-finish',
}
export enum TusdHookStorageType {
FileStore = 'filestore',
}
export class TusdStorageDto {
@IsEnum(TusdHookStorageType)
Type!: string;
@IsString()
@IsNotEmpty()
Path!: string;
@IsString()
@IsNotEmpty()
InfoPath!: string;
}
export class UploadAssetDataDto extends AssetMediaCreateDto {
@IsString()
@IsNotEmpty()
declare filename: string;
}
export class TusdMetaDataDto {
@IsString()
@IsNotEmpty()
declare AssetData: string; // base64-encoded JSON string of UploadAssetDataDto
}
export class TusdPreCreateUploadDto {
@IsInt()
Size!: number;
}
export class TusdPreFinishUploadDto {
@IsUUID()
ID!: string;
@IsInt()
Size!: number;
@Type(() => TusdMetaDataDto)
@ValidateNested()
@IsObject()
MetaData!: TusdMetaDataDto;
@Type(() => TusdStorageDto)
@ValidateNested()
@IsObject()
Storage!: TusdStorageDto;
}
export class TusdHttpRequestDto {
@IsString()
@IsNotEmpty()
Method!: string;
@IsString()
@IsNotEmpty()
URI!: string;
@IsObject()
Header!: Record<string, string[]>;
}
export class TusdPreCreateEventDto {
@Type(() => TusdPreCreateUploadDto)
@ValidateNested()
@IsObject()
Upload!: TusdPreCreateUploadDto;
@Type(() => TusdHttpRequestDto)
@ValidateNested()
@IsObject()
HTTPRequest!: TusdHttpRequestDto;
}
export class TusdPreFinishEventDto {
@Type(() => TusdPreFinishUploadDto)
@ValidateNested()
@IsObject()
Upload!: TusdPreFinishUploadDto;
@Type(() => TusdHttpRequestDto)
@ValidateNested()
@IsObject()
HTTPRequest!: TusdHttpRequestDto;
}
export class TusdHookRequestDto {
@IsEnum(TusdHookRequestType)
Type!: TusdHookRequestType;
@IsObject()
Event!: TusdPreCreateEventDto | TusdPreFinishEventDto;
}
export class TusdHttpResponseDto {
StatusCode!: number;
Body?: string;
Header?: Record<string, string>;
}
export class TusdChangeFileInfoStorageDto {
Path?: string;
}
export class TusdChangeFileInfoDto {
ID?: string;
MetaData?: TusdMetaDataDto;
Storage?: TusdChangeFileInfoStorageDto;
}
export class TusdHookResponseDto {
HTTPResponse?: TusdHttpResponseDto;
RejectUpload?: boolean;
ChangeFileInfo?: TusdChangeFileInfoDto;
}

View File

@@ -20,6 +20,7 @@ export enum ImmichHeader {
SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid',
AssetData = 'x-immich-asset-data',
}
export enum ImmichQuery {
@@ -493,6 +494,7 @@ export enum BootstrapEventPriority {
JobService = -190,
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
SystemConfig = 100,
UploadService = 200,
}
export enum QueueName {

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import archiver from 'archiver';
import chokidar, { ChokidarOptions } from 'chokidar';
import { escapePath, glob, globStream } from 'fast-glob';
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable, Writable } from 'node:stream';
@@ -134,6 +134,16 @@ export class StorageRepository {
}
}
unlinkSync(file: string) {
try {
unlinkSync(file);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw error;
}
}
}
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
await fs.rm(folder, options);
}
@@ -156,10 +166,13 @@ export class StorageRepository {
}
}
mkdir(filepath: string): Promise<String | undefined> {
return fs.mkdir(filepath, { recursive: true });
}
mkdirSync(filepath: string): void {
if (!existsSync(filepath)) {
mkdirSync(filepath, { recursive: true });
}
// does not throw an error if the folder already exists
mkdirSync(filepath, { recursive: true });
}
existsSync(filepath: string) {

View File

@@ -0,0 +1,318 @@
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { Request, Response } from 'express';
import { ChildProcess, spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { createConnection } from 'node:net';
import { extname, join, parse, resolve } from 'node:path';
import {
UPLOAD_CHUNK_DIRECTORY,
UPLOAD_TUSD_CONNECT_BACKOFF_MS,
UPLOAD_TUSD_CONNECT_RETRIES,
UPLOAD_TUSD_CONNECT_TIMEOUT_MS,
UPLOAD_TUSD_SOCKET_PATH,
} from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
TusdHookResponseDto,
TusdPreCreateEventDto,
TusdPreFinishEventDto,
UploadAssetDataDto,
} from 'src/dtos/upload.dto';
import { AssetVisibility, ImmichHeader, ImmichWorker, JobName, StorageFolder } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
@Injectable()
export class AssetUploadService extends BaseService {
private tusdProcess?: ChildProcess;
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] })
async onBootstrap() {
await this.storageRepository.unlinkDir(UPLOAD_CHUNK_DIRECTORY, { recursive: true, force: true });
await this.storageRepository.mkdir(UPLOAD_CHUNK_DIRECTORY);
const { host, port } = this.configRepository.getEnv();
const args = [
'-unix-sock',
UPLOAD_TUSD_SOCKET_PATH,
'--base-path',
'/api/upload/asset',
'--upload-dir',
UPLOAD_CHUNK_DIRECTORY,
'--hooks-http',
`http://${host ?? 'localhost'}:${port}:/api/upload/hook`,
'--hooks-enabled-events',
'pre-create,pre-finish',
'--behind-proxy',
'-enable-experimental-protocol',
'-shutdown-timeout',
'5s',
'-hooks-http-forward-headers',
Object.values(ImmichHeader).join(',') + ',authorization',
'-disable-download',
'-disable-termination',
];
this.logger.log(`Starting tusd server with args: ${args.join(' ')}`);
this.tusdProcess = spawn('tusd', args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.tusdProcess.stdout?.on('data', (data) => this.logger.verboseFn(() => `tusd: ${data.toString().trim()}`));
this.tusdProcess.stderr?.on('data', (data) => this.logger.warn(`tusd: ${data.toString().trim()}`));
this.tusdProcess.on('error', (error) => {
this.logger.error(`tusd failed to start: ${error.message}`);
process.exit(1);
});
this.tusdProcess.on('exit', async (code) => {
this.tusdProcess = undefined;
try {
await Promise.all([
this.storageRepository.unlink(UPLOAD_TUSD_SOCKET_PATH),
this.storageRepository.unlinkDir(UPLOAD_CHUNK_DIRECTORY, { recursive: true, force: true }),
]);
} finally {
if (code) {
this.logger.error(`tusd exited unexpectedly with code ${code}`);
// TODO: more graceful shutdown
process.exit(code);
}
}
});
this.logger.log('Waiting for tusd server...');
await this.waitForTusd(UPLOAD_TUSD_SOCKET_PATH);
this.logger.log('tusd server started successfully');
}
@OnEvent({ name: 'AppShutdown' })
onShutdown() {
this.tusdProcess?.kill('SIGTERM');
this.tusdProcess = undefined;
}
async proxyChunks(request: Request, response: Response): Promise<unknown> {
if (!this.tusdProcess) {
throw new InternalServerErrorException('tusd server is not running');
}
delete request.headers.host;
request.headers.connection = 'close'; // not sure why, but it doesn't respond for 60 seconds without this
return new Promise((resolve, reject) => {
const proxyReq = httpRequest(
{
socketPath: UPLOAD_TUSD_SOCKET_PATH,
path: request.url,
method: request.method,
headers: request.headers,
},
(proxyRes) => {
response.status(proxyRes.statusCode || 200);
for (const [key, value] of Object.entries(proxyRes.headers)) {
if (value !== undefined) {
response.setHeader(key, value);
}
}
proxyRes.pipe(response);
proxyRes.on('end', resolve);
proxyRes.on('error', reject);
},
);
proxyReq.on('error', (error: any) => {
this.logger.error(`Failed to proxy to tusd: ${error.message}`);
reject(new InternalServerErrorException('Upload service unavailable'));
});
if (request.readable) {
request.pipe(proxyReq);
} else {
proxyReq.end();
}
});
}
private async waitForTusd(socketPath: string, maxRetries = UPLOAD_TUSD_CONNECT_RETRIES): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
const ready = await new Promise<boolean>((resolve) => {
const socket = createConnection(socketPath, () => {
socket.end();
resolve(true);
});
socket.on('error', () => {
resolve(false);
});
socket.setTimeout(UPLOAD_TUSD_CONNECT_TIMEOUT_MS);
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
});
if (ready) {
return;
}
await new Promise((r) => setTimeout(r, UPLOAD_TUSD_CONNECT_BACKOFF_MS));
}
throw new Error('tusd server failed to start within timeout');
}
async handlePreCreate(
auth: AuthDto,
payload: TusdPreCreateEventDto,
headers: Record<string, string>,
): Promise<TusdHookResponseDto> {
this.logger.debugFn(() => `PreCreate hook received: ${JSON.stringify(payload)}`);
const checksum = headers[ImmichHeader.Checksum]?.[0];
if (checksum) {
const existingId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
if (existingId) {
const body = JSON.stringify({ status: AssetMediaStatus.DUPLICATE, id: existingId });
return {
RejectUpload: true,
HTTPResponse: { StatusCode: 200, Body: body },
};
}
}
this.requireQuota(auth, payload.Upload.Size);
const encodedAssetData = headers[ImmichHeader.AssetData];
if (!encodedAssetData) {
throw new BadRequestException(`Missing ${ImmichHeader.AssetData} header`);
}
const assetData = this.parseMetadata(encodedAssetData);
this.logger.log('assetData: ' + JSON.stringify(assetData));
const assetId = this.cryptoRepository.randomUUID();
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId);
const extension = extname(assetId);
const path = join(folder, `${assetId}${extension}`);
return {
ChangeFileInfo: {
ID: assetId,
Storage: { Path: path },
MetaData: {
AssetData: encodedAssetData,
},
},
};
}
async handlePreFinish(auth: AuthDto, dto: TusdPreFinishEventDto): Promise<TusdHookResponseDto> {
this.logger.debugFn(() => `PreFinish hook received: ${JSON.stringify(dto)}`);
const {
Upload: {
MetaData: { AssetData: encodedAssetData },
Storage: { Path: path },
Size: size,
ID: assetId,
},
} = dto;
const parsedPath = parse(resolve(path));
if (!parsedPath.dir.startsWith(StorageCore.getFolderLocation(StorageFolder.Upload, auth.user.id))) {
throw new BadRequestException('Path is not in user folder');
}
const metadata = this.parseMetadata(encodedAssetData);
try {
this.requireQuota(auth, size);
const checksum = await this.cryptoRepository.hashFile(path);
await this.storageRepository.utimes(path, new Date(), new Date(metadata.fileModifiedAt));
try {
await this.assetRepository.create({
id: assetId,
ownerId: auth.user.id,
libraryId: null,
checksum,
originalPath: path,
deviceAssetId: metadata.deviceAssetId,
deviceId: metadata.deviceId,
fileCreatedAt: metadata.fileCreatedAt,
fileModifiedAt: metadata.fileModifiedAt,
localDateTime: metadata.fileCreatedAt,
type: mimeTypes.assetType(path),
isFavorite: metadata.isFavorite,
duration: metadata.duration || null,
visibility: metadata.visibility || AssetVisibility.Timeline,
originalFileName: metadata.filename || parsedPath.base,
});
} catch (error: any) {
if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, checksum);
if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
void this.tryUnlink(path);
const body = JSON.stringify({ id: duplicateId, status: AssetMediaStatus.DUPLICATE });
return { HTTPResponse: { StatusCode: 200, Body: body } };
}
}
} catch (error: any) {
void this.tryUnlink(path);
throw error;
}
await this.userRepository.updateUsage(auth.user.id, size);
await this.assetRepository.upsertExif({ assetId: assetId, fileSizeInByte: size });
await this.jobRepository.queue({
name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' },
});
this.logger.log(`Asset created from chunked upload: ${assetId}`);
const body = JSON.stringify({ id: assetId, status: AssetMediaStatus.CREATED });
return { HTTPResponse: { StatusCode: 201, Body: body } };
}
private async tryUnlink(path: string): Promise<void> {
try {
await this.storageRepository.unlink(path);
} catch {
this.logger.warn(`Failed to remove file at ${path}`);
}
}
private requireQuota(auth: AuthDto, size: number) {
if (auth.user.quotaSizeInBytes === null) {
return;
}
if (auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
throw new BadRequestException('Quota has been exceeded!');
}
}
private parseMetadata(encodedAssetData: string): UploadAssetDataDto {
let assetData: any;
try {
assetData = JSON.parse(Buffer.from(encodedAssetData, 'base64').toString('utf8'));
} catch {
throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header`);
}
const dto = plainToInstance(UploadAssetDataDto, assetData);
const assetDataErrors = validateSync(dto, { whitelist: true });
if (assetDataErrors.length > 0 || !mimeTypes.isAsset(assetData.filename)) {
throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header`);
}
return dto;
}
}

View File

@@ -3,6 +3,7 @@ import { AlbumService } from 'src/services/album.service';
import { ApiKeyService } from 'src/services/api-key.service';
import { ApiService } from 'src/services/api.service';
import { AssetMediaService } from 'src/services/asset-media.service';
import { AssetUploadService } from 'src/services/asset-upload.service';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { AuthAdminService } from 'src/services/auth-admin.service';
@@ -47,6 +48,7 @@ export const services = [
AlbumService,
ApiService,
AssetMediaService,
AssetUploadService,
AssetService,
AuditService,
AuthService,

View File

@@ -161,24 +161,7 @@ export class NotificationService extends BaseService {
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
if (asset) {
// need to specify authDto to this mapAsset request, because it tries to prevent
// leaking information PR#7580 which expects a userId in the auth options object
this.eventRepository.clientSend(
'on_asset_update',
userId,
mapAsset(asset, {
auth: {
user: {
id: userId,
isAdmin: false,
name: '',
email: '',
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
},
},
}),
);
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset));
}
}

View File

@@ -121,7 +121,6 @@
const onMouseLeave = () => {
mouseOver = false;
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
};
let timer: ReturnType<typeof setTimeout> | null = null;

View File

@@ -1,68 +0,0 @@
<script lang="ts">
import { uploadAssetsStore } from '$lib/stores/upload';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
let { isUploading } = uploadAssetsStore;
interface Props {
viewerAssets: ViewerAsset[];
width: number;
height: number;
photostreamManager: PhotostreamManager;
thumbnail: Snippet<
[
{
asset: TimelineAsset;
position: CommonPosition;
},
]
>;
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
}
let { viewerAssets, width, height, photostreamManager, thumbnail, customThumbnailLayout }: Props = $props();
const transitionDuration = $derived.by(() => (photostreamManager.suspendTransitions && !$isUploading ? 0 : 150));
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting);
}
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
{@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)}
</div>
{/each}
</div>
<style>
[data-image-grid] {
user-select: none;
}
</style>

View File

@@ -1,109 +0,0 @@
<script lang="ts">
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
let { isUploading } = uploadAssetsStore;
interface Props {
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
onDayGroupSelect: (daygroup: DayGroup, assets: TimelineAsset[]) => void;
}
let {
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
monthGroup,
timelineManager,
onDayGroupSelect,
}: Props = $props();
let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state();
const transitionDuration = $derived.by(() =>
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting);
}
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => {
isMouseOverGroup = true;
hoveredDayGroup = dayGroup.groupTitle;
}}
onmouseleave={() => {
isMouseOverGroup = false;
hoveredDayGroup = null;
}}
>
<!-- Date group title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
>
{#if isDayGroupSelected}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={dayGroup.groupTitleFull}>
{dayGroup.groupTitle}
</span>
</div>
<AssetLayout
photostreamManager={timelineManager}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}
{customThumbnailLayout}
>
{#snippet thumbnail({ asset, position })}
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
{/snippet}
</AssetLayout>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
</style>

View File

@@ -3,7 +3,7 @@
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es';
@@ -11,31 +11,18 @@
import { fade, fly } from 'svelte/transition';
interface Props {
/** Offset from the top of the timeline (e.g., for headers) */
timelineTopOffset?: number;
/** Offset from the bottom of the timeline (e.g., for footers) */
timelineBottomOffset?: number;
/** Total height of the scrubber component */
height?: number;
/** Timeline manager instance that controls the timeline state */
timelineManager: TimelineManager;
/** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */
timelineScrollPercent?: number;
/** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */
viewportTopMonthScrollPercent?: number;
/** The year/month of the timeline month at the top of the viewport */
viewportTopMonth?: TimelineYearMonth;
/** Indicates whether the viewport is currently in the lead-out section (after all months) */
isInLeadOutSection?: boolean;
/** Width of the scrubber component in pixels (bindable for parent component margin adjustments) */
scrubOverallPercent?: number;
scrubberMonthPercent?: number;
scrubberMonth?: { year: number; month: number };
leadout?: boolean;
scrubberWidth?: number;
/** Callback fired when user interacts with the scrubber to navigate */
onScrub?: ScrubberListener;
/** Callback fired when keyboard events occur on the scrubber */
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
/** Callback fired when scrubbing starts */
startScrub?: ScrubberListener;
/** Callback fired when scrubbing stops */
stopScrub?: ScrubberListener;
}
@@ -44,10 +31,10 @@
timelineBottomOffset = 0,
height = 0,
timelineManager,
timelineScrollPercent = 0,
viewportTopMonthScrollPercent = 0,
viewportTopMonth = undefined,
isInLeadOutSection = false,
scrubOverallPercent = 0,
scrubberMonthPercent = 0,
scrubberMonth = undefined,
leadout = false,
onScrub = undefined,
onScrubKeyDown = undefined,
startScrub = undefined,
@@ -113,7 +100,7 @@
offset += scrubberMonthPercent * relativeBottomOffset;
}
return offset;
} else if (isInLeadOutSection) {
} else if (leadout) {
let offset = relativeTopOffset;
for (const segment of segments) {
offset += segment.height;
@@ -124,9 +111,7 @@
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
}
};
let scrollY = $derived(
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
);
let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent));
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
@@ -310,24 +295,12 @@
const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) {
void startScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
void onScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
}
if (wasDragging && !isDragging) {
void stopScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
return;
}
@@ -335,11 +308,7 @@
return;
}
void onScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
};
/* eslint-disable tscompat/tscompat */
const getTouch = (event: TouchEvent) => {
@@ -443,11 +412,7 @@
}
if (next) {
event.preventDefault();
void onScrub?.({
scrubberMonth: { year: next.year, month: next.month },
overallScrollPercent: -1,
scrubberMonthScrollPercent: 0,
});
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
return true;
}
}
@@ -457,11 +422,7 @@
const next = segments[idx + 1];
if (next) {
event.preventDefault();
void onScrub?.({
scrubberMonth: { year: next.year, month: next.month },
overallScrollPercent: -1,
scrubberMonthScrollPercent: 0,
});
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
return true;
}
}

View File

@@ -1,62 +0,0 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { Snippet } from 'svelte';
interface Props {
content: Snippet<
[
{
onDayGroupSelect: (dayGroup: DayGroup, asset: TimelineAsset[]) => void;
onDayGroupAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
},
]
>;
onAssetSelect: (asset: TimelineAsset) => void;
assetInteraction: AssetInteraction;
}
let { content, assetInteraction, onAssetSelect }: Props = $props();
// called when clicking asset with shift key pressed or with mouse
const onDayGroupAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => {
onAssetSelect(asset);
const assetsInDayGroup = dayGroup.getAssets();
const groupTitle = dayGroup.groupTitle;
// Check if all assets are selected in a group to toggle the group selection's icon
const selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
};
const onDayGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
const group = dayGroup.groupTitle;
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
onAssetSelect(asset);
}
}
};
</script>
{@render content({
onDayGroupSelect,
onDayGroupAssetSelect,
})}

View File

@@ -1,208 +0,0 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import type { Snippet } from 'svelte';
interface Props {
content: Snippet<
[
{
onAssetOpen: (asset: TimelineAsset) => void;
onAssetSelect: (asset: TimelineAsset) => void;
onAssetHover: (asset: TimelineAsset | null) => void;
},
]
>;
segment: PhotostreamSegment;
isSelectionMode: boolean;
singleSelect: boolean;
timelineManager: PhotostreamManager;
assetInteraction: AssetInteraction;
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetSelect?: (asset: TimelineAsset) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
}
let {
segment,
content,
isSelectionMode,
singleSelect,
assetInteraction,
timelineManager,
onAssetOpen,
onAssetSelect,
onScrollCompensationMonthInDOM,
}: Props = $props();
let shiftKeyIsDown = $state(false);
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
let lastMouseHoverAsset: TimelineAsset | null = $state(null);
$effect(() => {
if (shiftKeyIsDown && lastMouseHoverAsset) {
void selectAssetCandidates(lastMouseHoverAsset);
}
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const defaultAssetOpen = (asset: TimelineAsset) => {
if (isSelectionMode || assetInteraction.selectionActive) {
handleAssetSelect(asset);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleOnAssetOpen = (asset: TimelineAsset) => {
if (onAssetOpen) {
onAssetOpen(asset, () => defaultAssetOpen(asset));
return;
}
defaultAssetOpen(asset);
};
// called when clicking asset with shift key pressed or with mouse
const handleAssetSelect = (asset: TimelineAsset) => {
handleSelectAssets(asset);
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
assetInteraction.clearAssetSelectionCandidates();
if (lastMouseHoverAsset) {
void selectAssetCandidates(lastMouseHoverAsset);
return;
}
if (!assetInteraction.assetSelectionStart) {
assetInteraction.setAssetSelectionStart(assetInteraction.selectedAssets.at(-1) ?? null);
}
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
assetInteraction.clearAssetSelectionCandidates();
}
};
const handleOnHover = (asset: TimelineAsset | null) => {
if (asset) {
if (assetInteraction.selectionActive) {
void selectAssetCandidates(asset);
}
lastMouseHoverAsset = asset;
}
};
const handleSelectAssets = (asset: TimelineAsset) => {
if (!asset) {
return;
}
onAssetSelect?.(asset);
if (singleSelect) {
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
const assets = timelineManager.retrieveLoadedRange(assetInteraction.assetSelectionStart, asset);
for (const asset of assets) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
return null;
};
const handleSelectAsset = (asset: TimelineAsset) => {
if ('albumAssets' in timelineManager) {
const tm = timelineManager as TimelineManager;
if (tm.albumAssets.has(asset.id)) {
return;
}
}
assetInteraction.selectAsset(asset);
};
const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(timelineManager.retrieveLoadedRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === segment) {
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
}
});
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
{@render content({
onAssetOpen: handleOnAssetOpen,
onAssetSelect: (asset) => {
void handleSelectAssets(asset);
},
onAssetHover: handleOnHover,
})}

View File

@@ -1,35 +1,46 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AbsoluteResult, RelativeResult } from '$lib/components/shared-components/change-date.svelte';
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import SelectableDay from '$lib/components/timeline/SelectableDay.svelte';
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import { AssetAction } from '$lib/constants';
import {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
} from '$lib/components/timeline/actions/focus-actions';
import { AppRoute, AssetAction } from '$lib/constants';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import {
getSegmentIdentifier,
getTimes,
type ScrubberListener,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import DeleteAssetDialog from '../photos-page/delete-asset-dialog.svelte';
import TimelineDateGroup from './TimelineDateGroup.svelte';
interface Props {
isSelectionMode?: boolean;
@@ -53,12 +64,22 @@
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetSelect?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void;
children?: Snippet;
empty?: Snippet;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
customLayout?: Snippet<[TimelineAsset]>;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
}
let {
@@ -74,36 +95,30 @@
album = null,
person = null,
isShowDeleteConfirmation = $bindable(false),
onAssetSelect,
onAssetOpen,
onSelect = () => {},
onEscape = () => {},
children,
empty,
customThumbnailLayout,
customLayout,
onThumbnailClick,
}: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
let showSkeleton = $state(true);
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
// Note: There may be multiple months visible within the viewport at any given time.
let viewportTopMonthScrollPercent = $state(0);
// The timeline month intersecting the top position of the viewport
let viewportTopMonth: { year: number; month: number } | undefined = $state(undefined);
// Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0);
let isShowSelectDate = $state(false);
let scrubberMonthPercent = $state(0);
let scrubberMonth: { year: number; month: number } | undefined = $state(undefined);
let scrubOverallPercent: number = $state(0);
let scrubberWidth = $state(0);
// 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60;
// Indicates whether the viewport is currently in the lead-out section (after all months)
let isInLeadOutSection = $state(false);
let leadout = $state(false);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mobileDevice.maxMd);
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
@@ -139,26 +154,14 @@
scrollTo(0);
};
const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => {
const { heightDelta, scrollTop } = compensation;
if (heightDelta !== undefined) {
scrollBy(heightDelta);
} else if (scrollTop !== undefined) {
scrollTo(scrollTop);
}
timelineManager.clearScrollCompensation();
};
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
// the following method may trigger any layouts, so need to
// handle any scroll compensation that may have been set
const height = monthGroup!.findAssetAbsolutePosition(assetId);
// this is in a while loop, since scrollCompensations invoke scrolls
// which may load months, triggering more scrollCompensations. Call
// this in a loop, until no more layouts occur.
while (timelineManager.scrollCompensation.monthGroup) {
handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
handleScrollCompensation(timelineManager.scrollCompensation);
timelineManager.clearScrollCompensation();
}
return height;
};
@@ -254,6 +257,19 @@
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
if (heightDelta !== undefined) {
scrollBy(heightDelta);
} else if (scrollTop !== undefined) {
scrollTo(scrollTop);
}
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
// the above calls. However, this delay is enough time to set the intersecting property
// of the monthGroup to false, then true, which causes the DOM nodes to be recreated,
// causing bad perf, and also, disrupting focus of those elements.
updateSlidingWindow();
};
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => {
@@ -285,19 +301,20 @@
scrollTop(scrollToTop);
};
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
const onScrub: ScrubberListener = (scrubberData) => {
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const onScrub: ScrubberListener = (
scrubMonth: { year: number; month: number },
overallScrollPercent: number,
scrubberMonthScrollPercent: number,
) => {
if (!scrubMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll();
const offset = maxScroll * overallScrollPercent;
scrollTop(offset);
} else {
const monthGroup = timelineManager.months.find(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
({ yearMonth: { year, month } }) => year === scrubMonth.year && month === scrubMonth.month,
);
if (!monthGroup) {
return;
@@ -308,7 +325,7 @@
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const handleTimelineScroll = () => {
isInLeadOutSection = false;
leadout = false;
if (!element) {
return;
@@ -317,19 +334,19 @@
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll();
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0;
scrubberMonth = undefined;
scrubberMonthPercent = 0;
} else {
let top = element.scrollTop;
if (top < timelineManager.topSectionHeight) {
// in the lead-in area
viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0;
scrubberMonth = undefined;
scrubberMonthPercent = 0;
const maxScroll = getMaxScroll();
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
return;
}
@@ -354,15 +371,15 @@
let next = top - monthGroupHeight * maxScrollPercent;
// instead of checking for < 0, add a little wiggle room for subpixel resolution
if (next < -1 && monthGroup) {
viewportTopMonth = monthGroup;
scrubberMonth = monthGroup;
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
scrubberMonthPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
// compensate for lost precision/rounding errors advance to the next bucket, if present
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
viewportTopMonthScrollPercent = 0;
if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) {
scrubberMonth = timelineManager.months[i + 1].yearMonth;
scrubberMonthPercent = 0;
}
found = true;
@@ -371,31 +388,465 @@
top = next;
}
if (!found) {
isInLeadOutSection = true;
viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0;
timelineScrollPercent = 1;
leadout = true;
scrubberMonth = undefined;
scrubberMonthPercent = 0;
scrubOverallPercent = 1;
}
}
};
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
);
assetInteraction.clearMultiselect();
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const onStackAssets = async () => {
const result = await stackAssets(assetInteraction.selectedAssets);
updateStackedAssetInTimeline(timelineManager, result);
onEscape();
};
const toggleArchive = async () => {
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
});
deselectAllAssets();
};
const handleSelectAsset = (asset: TimelineAsset) => {
if (!timelineManager.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
};
const handlePrevious = async () => {
const release = await mutex.acquire();
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
release();
return !!laterAsset;
};
const handleNext = async () => {
const release = await mutex.acquire();
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
release();
return !!earlierAsset;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
}
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
break;
}
}
};
const handleAction = (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
timelineManager.updateAssets([action.asset]);
break;
}
case AssetAction.ADD: {
timelineManager.addAssets([action.asset]);
break;
}
case AssetAction.UNSTACK: {
updateUnstackedAssetInTimeline(timelineManager, action.assets);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.addAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack?.primaryAssetId)
.map((asset) => asset.id),
});
}
break;
}
case AssetAction.SET_STACK_PRIMARY_ASSET: {
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
}
};
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false);
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
void selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const handleGroupSelect = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => {
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
handleSelectAsset(asset);
}
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const handleSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
onSelect(asset);
if (singleSelect) {
scrollTop(0);
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
if (startBucket === null || endBucket === null) {
return;
}
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
}
if (monthGroup === endBucket) {
break;
}
}
}
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
const onSelectStart = (e: Event) => {
if (assetInteraction.selectionActive && shiftKeyIsDown) {
e.preventDefault();
}
};
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
$effect(() => {
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
];
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
})(),
);
$effect(() => {
if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (!shiftKeyIsDown) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
void selectAssetCandidates(lastAssetMouseEvent);
}
});
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
}
});
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
<TimelineKeyboardActions
scrollToAsset={(asset) => scrollToAsset(asset) ?? false}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
{onEscape}
/>
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={idsSelectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}
{#if isShowSelectDate}
<ChangeDate
title="Navigate to Time"
initialDate={DateTime.now()}
timezoneInput={false}
onConfirm={async (dateString: AbsoluteResult | RelativeResult) => {
isShowSelectDate = false;
if (dateString.mode == 'absolute') {
const asset = await timelineManager.getClosestAssetToDate(
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
);
if (asset) {
setFocusAsset(asset);
}
}
}}
onCancel={() => (isShowSelectDate = false)}
/>
{/if}
{#if timelineManager.months.length > 0}
<Scrubber
@@ -403,12 +854,27 @@
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={bottomSectionHeight}
{isInLeadOutSection}
{timelineScrollPercent}
{viewportTopMonthScrollPercent}
{viewportTopMonth}
{leadout}
{scrubOverallPercent}
{scrubberMonthPercent}
{scrubberMonth}
{onScrub}
bind:scrubberWidth
onScrubKeyDown={(evt) => {
evt.preventDefault();
let amount = 50;
if (shiftKeyIsDown) {
amount = 500;
}
if (evt.key === 'ArrowUp') {
amount = -amount;
if (shiftKeyIsDown) {
element?.scrollBy({ top: amount, behavior: 'smooth' });
}
} else if (evt.key === 'ArrowDown') {
element?.scrollBy({ top: amount, behavior: 'smooth' });
}
}}
/>
{/if}
@@ -467,58 +933,21 @@
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<SelectableSegment
segment={monthGroup}
onScrollCompensationMonthInDOM={handleTriggeredScrollCompensation}
{timelineManager}
<TimelineDateGroup
{withStacked}
{showArchiveIcon}
{assetInteraction}
{timelineManager}
{isSelectionMode}
{singleSelect}
{onAssetOpen}
{onAssetSelect}
>
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
<SelectableDay {assetInteraction} {onAssetSelect}>
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
<MonthSegment
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
{timelineManager}
{onDayGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={() => onAssetOpen(asset)}
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
onMouseEvent={(isMouseOver) => {
if (isMouseOver) {
onAssetHover(asset);
} else {
onAssetHover(null);
}
}}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</MonthSegment>
{/snippet}
</SelectableDay>
{/snippet}
</SelectableSegment>
{monthGroup}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
onScrollCompensation={handleScrollCompensation}
{customLayout}
{onThumbnailClick}
/>
</div>
{/if}
{/each}
@@ -535,7 +964,22 @@
<Portal target="body">
{#if $showAssetViewer}
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/await}
{/if}
</Portal>

View File

@@ -1,177 +0,0 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
showSkeleton: boolean;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE
| null;
}
let {
timelineManager,
showSkeleton = $bindable(false),
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
}: Props = $props();
const handlePrevious = async () => {
const release = await mutex.acquire();
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
release();
return !!laterAsset;
};
const handleNext = async () => {
const release = await mutex.acquire();
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
release();
return !!earlierAsset;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
}
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
break;
}
}
};
const handleAction = (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
timelineManager.upsertAssets([action.asset]);
break;
}
case AssetAction.ADD: {
timelineManager.upsertAssets([action.asset]);
break;
}
case AssetAction.UNSTACK: {
updateUnstackedAssetInTimeline(timelineManager, action.assets);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack?.primaryAssetId)
.map((asset) => asset.id),
});
}
break;
}
case AssetAction.SET_STACK_PRIMARY_ASSET: {
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
}
};
</script>
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/await}

View File

@@ -0,0 +1,254 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
interface Props {
isSelectionMode: boolean;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
customLayout?: Snippet<[TimelineAsset]>;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
}
let {
isSelectionMode,
singleSelect,
withStacked,
showArchiveIcon,
monthGroup = $bindable(),
assetInteraction,
timelineManager,
customLayout,
onSelect,
onSelectAssets,
onSelectAssetCandidates,
onScrollCompensation,
onThumbnailClick,
}: Props = $props();
let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state();
const transitionDuration = $derived.by(() =>
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[],
groupTitle: string,
) => {
onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group
hoveredDayGroup = groupTitle;
if (assetInteraction.selectionActive) {
onSelectAssetCandidates(asset);
}
};
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting);
}
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
});
return getDateLocaleString(date);
};
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
onScrollCompensation(timelineManager.scrollCompensation);
timelineManager.clearScrollCompensation();
}
});
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(dayGroup.groupTitle, null);
}}
onmouseleave={() => {
isMouseOverGroup = false;
assetMouseEventHandler(dayGroup.groupTitle, null);
}}
>
<!-- Date group title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
>
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
</span>
</div>
<!-- Image grid -->
<div
data-image-grid
class="relative overflow-clip"
style:height={dayGroup.height + 'px'}
style:width={dayGroup.width + 'px'}
>
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- {#if viewerAsset.intersecting} -->
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) ||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{#if customLayout}
{@render customLayout(asset)}
{/if}
</div>
<!-- {/if} -->
{/each}
</div>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
[data-image-grid] {
user-select: none;
}
</style>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { OnArchive } from '$lib/utils/actions';
import { archiveAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
@@ -13,10 +12,9 @@
onArchive?: OnArchive;
menuItem?: boolean;
unarchive?: boolean;
manager?: PhotostreamManager;
}
let { onArchive, menuItem = false, unarchive = false, manager }: Props = $props();
let { onArchive, menuItem = false, unarchive = false }: Props = $props();
let text = $derived(unarchive ? $t('unarchive') : $t('to_archive'));
let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
@@ -26,13 +24,12 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => {
const visibility = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== visibility);
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
loading = true;
const ids = await archiveAssets(assets, visibility as AssetVisibility);
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
if (ids) {
manager?.updateAssetOperation(ids, (asset) => ((asset.visibility = visibility), void 0));
onArchive?.(ids, visibility ? AssetVisibility.Archive : AssetVisibility.Timeline);
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
clearSelect();
}
loading = false;

View File

@@ -1,8 +1,6 @@
<script lang="ts">
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { featureFlags } from '$lib/stores/server-config.store';
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
import { IconButton } from '@immich/ui';
@@ -11,14 +9,13 @@
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
onAssetDelete?: OnDelete;
onUndoDelete?: OnUndoDelete;
onAssetDelete: OnDelete;
onUndoDelete?: OnUndoDelete | undefined;
menuItem?: boolean;
force?: boolean;
manager?: PhotostreamManager;
}
let { onAssetDelete, onUndoDelete, menuItem = false, force = !$featureFlags.trash, manager }: Props = $props();
let { onAssetDelete, onUndoDelete = undefined, menuItem = false, force = !$featureFlags.trash }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
@@ -39,12 +36,7 @@
const handleDelete = async () => {
loading = true;
const assets = [...getOwnedAssets()];
const undo = (assets: TimelineAsset[]) => {
manager?.upsertAssets(assets);
onUndoDelete?.(assets);
};
await deleteAssets(force, onAssetDelete, assets, undo);
manager?.removeAssets(assets.map((asset) => asset.id));
await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
clearSelect();
isShowConfirmation = false;
loading = false;

View File

@@ -5,7 +5,6 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { OnFavorite } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
@@ -17,10 +16,9 @@
onFavorite?: OnFavorite;
menuItem?: boolean;
removeFavorite: boolean;
manager?: PhotostreamManager;
}
let { onFavorite, menuItem = false, removeFavorite, manager }: Props = $props();
let { onFavorite, menuItem = false, removeFavorite }: Props = $props();
let text = $derived(removeFavorite ? $t('remove_from_favorites') : $t('to_favorite'));
let icon = $derived(removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline);
@@ -41,7 +39,11 @@
if (ids.length > 0) {
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
manager?.updateAssetOperation(ids, (asset) => ((asset.isFavorite = isFavorite), void 0));
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
onFavorite?.(ids, isFavorite);
notificationController.show({

View File

@@ -1,221 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import ChangeDate, {
type AbsoluteResult,
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
} from '$lib/components/timeline/actions/focus-actions';
import { AppRoute } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { DateTime } from 'luxon';
let { isViewing: showAssetViewer } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
isShowDeleteConfirmation: boolean;
onEscape?: () => void;
scrollToAsset: (asset: TimelineAsset) => boolean;
}
let {
timelineManager = $bindable(),
assetInteraction,
isShowDeleteConfirmation = $bindable(false),
onEscape,
scrollToAsset,
}: Props = $props();
let isShowSelectDate = $state(false);
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.upsertAssets(assets),
);
assetInteraction.clearMultiselect();
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const onStackAssets = async () => {
const result = await stackAssets(assetInteraction.selectedAssets);
updateStackedAssetInTimeline(timelineManager, result);
onEscape?.();
};
const toggleArchive = async () => {
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
});
deselectAllAssets();
};
let shiftKeyIsDown = $state(false);
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const onSelectStart = (e: Event) => {
if (assetInteraction.selectionActive && shiftKeyIsDown) {
e.preventDefault();
}
};
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
$effect(() => {
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
})(),
);
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={idsSelectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}
{#if isShowSelectDate}
<ChangeDate
withDuration={false}
title="Navigate to Time"
initialDate={DateTime.now()}
timezoneInput={false}
onConfirm={async (dateString: AbsoluteResult | RelativeResult) => {
isShowSelectDate = false;
if (dateString.mode == 'absolute') {
const asset = await timelineManager.getClosestAssetToDate(
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
);
if (asset) {
setFocusAsset(asset);
}
}
}}
onCancel={() => (isShowSelectDate = false)}
/>
{/if}

View File

@@ -1,431 +0,0 @@
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { clamp, debounce } from 'lodash-es';
import {
PhotostreamSegment,
type SegmentIdentifier,
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { updateObject } from '$lib/managers/timeline-manager/internal/utils.svelte';
import type {
AssetDescriptor,
AssetOperation,
MoveAsset,
TimelineAsset,
TimelineManagerLayoutOptions,
Viewport,
} from '$lib/managers/timeline-manager/types';
import { setDifference } from '$lib/utils/timeline-util';
export abstract class PhotostreamManager {
isInitialized = $state(false);
topSectionHeight = $state(0);
bottomSectionHeight = $state(60);
abstract get months(): PhotostreamSegment[];
timelineHeight = $derived.by(
() => this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight,
);
assetCount = $derived.by(() => this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
topIntersectingMonthGroup: PhotostreamSegment | undefined = $state();
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
}));
protected initTask = new CancellableTask(
() => (this.isInitialized = true),
() => (this.isInitialized = false),
() => void 0,
);
#viewportHeight = $state(0);
#viewportWidth = $state(0);
#scrollTop = $state(0);
#rowHeight = $state(235);
#headerHeight = $state(48);
#gap = $state(12);
#scrolling = $state(false);
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
scrollCompensation: {
heightDelta: number | undefined;
scrollTop: number | undefined;
monthGroup: PhotostreamSegment | undefined;
} = $state({
heightDelta: 0,
scrollTop: 0,
monthGroup: undefined,
});
constructor() {}
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
let changed = false;
changed ||= this.#setHeaderHeight(headerHeight);
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight);
if (changed) {
this.refreshLayout();
}
}
#setHeaderHeight(value: number) {
if (this.#headerHeight == value) {
return false;
}
this.#headerHeight = value;
return true;
}
get headerHeight() {
return this.#headerHeight;
}
#setGap(value: number) {
if (this.#gap == value) {
return false;
}
this.#gap = value;
return true;
}
get gap() {
return this.#gap;
}
#setRowHeight(value: number) {
if (this.#rowHeight == value) {
return false;
}
this.#rowHeight = value;
return true;
}
get rowHeight() {
return this.#rowHeight;
}
set scrolling(value: boolean) {
this.#scrolling = value;
if (value) {
this.suspendTransitions = true;
this.#resetScrolling();
}
}
get scrolling() {
return this.#scrolling;
}
set suspendTransitions(value: boolean) {
this.#suspendTransitions = value;
if (value) {
this.#resetSuspendTransitions();
}
}
get suspendTransitions() {
return this.#suspendTransitions;
}
set viewportWidth(value: number) {
const changed = value !== this.#viewportWidth;
this.#viewportWidth = value;
this.suspendTransitions = true;
void this.updateViewportGeometry(changed);
}
get viewportWidth() {
return this.#viewportWidth;
}
set viewportHeight(value: number) {
this.#viewportHeight = value;
this.#suspendTransitions = true;
void this.updateViewportGeometry(false);
}
get viewportHeight() {
return this.#viewportHeight;
}
updateSlidingWindow(scrollTop: number) {
if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop;
this.updateIntersections();
}
}
clearScrollCompensation() {
this.scrollCompensation = {
heightDelta: undefined,
scrollTop: undefined,
monthGroup: undefined,
};
}
updateIntersections() {
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return;
}
let topIntersectingMonthGroup = undefined;
for (const month of this.months) {
updateIntersectionMonthGroup(this, month);
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
topIntersectingMonthGroup = month;
}
}
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
}
for (const month of this.months) {
if (month === this.topIntersectingMonthGroup) {
this.topIntersectingMonthGroup.percent = clamp(
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
0,
1,
);
} else {
month.percent = 0;
}
}
}
async init() {
this.isInitialized = false;
await this.initTask.execute(() => Promise.resolve(undefined), true);
}
public destroy() {
this.isInitialized = false;
}
async updateViewport(viewport: Viewport) {
if (viewport.height === 0 && viewport.width === 0) {
return;
}
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
return;
}
if (!this.initTask.executed) {
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.init());
}
const changedWidth = viewport.width !== this.viewportWidth;
this.viewportHeight = viewport.height;
this.viewportWidth = viewport.width;
this.updateViewportGeometry(changedWidth);
}
protected updateViewportGeometry(changedWidth: boolean) {
if (!this.isInitialized) {
return;
}
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
return;
}
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: changedWidth });
}
this.updateIntersections();
}
createLayoutOptions() {
const viewportWidth = this.viewportWidth;
return {
spacing: 2,
heightTolerance: 0.15,
rowHeight: this.#rowHeight,
rowWidth: Math.floor(viewportWidth),
};
}
async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true;
if (options) {
cancelable = options.cancelable;
}
const segment = this.getSegmentByIdentifier(identifier);
if (!segment) {
return;
}
if (segment.loader?.executed) {
return;
}
const result = await segment.load(cancelable);
if (result === 'LOADED') {
updateIntersectionMonthGroup(this, segment);
}
}
getSegmentByIdentifier(identifier: SegmentIdentifier) {
return this.months.find((segment) => identifier.matches(segment));
}
getSegmentForAssetId(assetId: string) {
for (const month of this.months) {
const asset = month.assets.find((asset) => asset.id === assetId);
if (asset) {
return month;
}
}
}
refreshLayout() {
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: true });
}
this.updateIntersections();
}
getMaxScrollPercent() {
const totalHeight = this.timelineHeight + this.bottomSectionHeight + this.topSectionHeight;
return (totalHeight - this.viewportHeight) / totalHeight;
}
getMaxScroll() {
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
}
retrieveLoadedRange(start: AssetDescriptor, end: AssetDescriptor): TimelineAsset[] {
const range: TimelineAsset[] = [];
let collecting = false;
for (const month of this.months) {
if (collecting && !month.isLoaded) {
// if there are any unloaded months in the range, return empty []
return [];
}
for (const asset of month.assets) {
if (asset.id === start.id) {
collecting = true;
}
if (collecting) {
range.push(asset);
}
if (asset.id === end.id) {
return range;
}
}
}
return range;
}
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
return Promise.resolve(this.retrieveLoadedRange(start, end));
}
updateAssetOperation(ids: string[], operation: AssetOperation) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
return this.#runAssetOperation(new Set(ids), operation);
}
upsertAssets(assets: TimelineAsset[]) {
const notExcluded = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.#updateAssets(notExcluded);
this.addAssetsToSegments([...notUpdated]);
}
#updateAssets(assets: TimelineAsset[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) =>
updateObject(asset, lookup.get(asset.id)),
);
const result: TimelineAsset[] = [];
for (const id of unprocessedIds.values()) {
result.push(lookup.get(id)!);
}
return result;
}
removeAssets(ids: string[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => ({ remove: true }));
return [...unprocessedIds];
}
isExcluded(_: TimelineAsset) {
return false;
}
protected createUpsertContext(): unknown {
return;
}
protected abstract upsertAssetIntoSegment(asset: TimelineAsset, context: unknown): void;
protected postCreateSegments(): void {}
protected postUpsert(_: unknown): void {}
protected addAssetsToSegments(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const context = this.createUpsertContext();
const monthCount = this.months.length;
for (const asset of assets) {
this.upsertAssetIntoSegment(asset, context);
}
if (this.months.length !== monthCount) {
this.postCreateSegments();
}
this.postUpsert(context);
this.updateIntersections();
}
#runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const changedMonthGroups = new Set<PhotostreamSegment>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
let idsToProcess = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsProcessed = new Set<string>();
const combinedMoveAssets: MoveAsset[][] = [];
for (const month of this.months) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = setDifference(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonthGroups.add(month);
}
}
}
if (combinedMoveAssets.length > 0) {
this.addAssetsToSegments(combinedMoveAssets.flat().map((a) => a.asset));
}
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(this, month, { invalidateHeight: true });
}
if (changedGeometry) {
this.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}
}

View File

@@ -1,195 +0,0 @@
import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { AssetOperation, MoveAsset, TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
export type SegmentIdentifier = {
matches(segment: PhotostreamSegment): boolean;
};
export abstract class PhotostreamSegment {
#intersecting = $state(false);
actuallyIntersecting = $state(false);
#isLoaded = $state(false);
#height = $state(0);
#top = $state(0);
#assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset));
initialCount = $state(0);
percent = $state(0);
assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount));
loader = new CancellableTask(
() => this.markLoaded(),
() => this.markCanceled,
() => this.handleLoadError,
);
isHeightActual = $state(false);
abstract get timelineManager(): PhotostreamManager;
abstract get identifier(): SegmentIdentifier;
abstract get id(): string;
get isLoaded() {
return this.#isLoaded;
}
protected markLoaded() {
this.#isLoaded = true;
}
protected markCanceled() {
this.#isLoaded = false;
}
set intersecting(newValue: boolean) {
const old = this.#intersecting;
if (old === newValue) {
return;
}
this.#intersecting = newValue;
if (newValue) {
void this.load(true);
} else {
this.cancel();
}
}
get intersecting() {
return this.#intersecting;
}
async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
return await this.loader?.execute(async (signal: AbortSignal) => {
await this.fetch(signal);
}, cancelable);
}
protected abstract fetch(signal: AbortSignal): Promise<void>;
get assets(): TimelineAsset[] {
return this.#assets;
}
abstract get viewerAssets(): ViewerAsset[];
set height(height: number) {
if (this.#height === height) {
return;
}
const { timelineManager: store, percent } = this;
const index = store.months.indexOf(this);
const heightDelta = height - this.#height;
this.#height = height;
const prevMonthGroup = store.months[index - 1];
if (prevMonthGroup) {
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
const monthGroup = this.timelineManager.months[cursor];
const newTop = monthGroup.#top + heightDelta;
if (monthGroup.#top !== newTop) {
monthGroup.#top = newTop;
}
}
if (store.topIntersectingMonthGroup) {
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
if (currentIndex > 0) {
if (index < currentIndex) {
store.scrollCompensation = {
heightDelta,
scrollTop: undefined,
monthGroup: this,
};
} else if (percent > 0) {
const top = this.top + height * percent;
store.scrollCompensation = {
heightDelta: undefined,
scrollTop: top,
monthGroup: this,
};
}
}
}
}
get height() {
return this.#height;
}
get top(): number {
return this.#top + this.timelineManager.topSectionHeight;
}
protected handleLoadError(error: unknown) {
const _$t = get(t);
handleError(error, _$t('errors.failed_to_load_assets'));
}
cancel() {
this.loader?.cancel();
}
layout(_: boolean) {}
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
this.intersecting = intersecting;
this.actuallyIntersecting = actuallyIntersecting;
}
abstract findAssetAbsolutePosition(assetId: string): number;
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
// eslint-disable-next-line svelte/prefer-svelte-reactivity
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const unprocessedIds = new Set<string>(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const processedIds = new Set<string>();
const moveAssets: MoveAsset[] = [];
let changedGeometry = false;
for (const assetId of unprocessedIds) {
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
if (index === -1) {
continue;
}
const asset = this.viewerAssets[index].asset!;
const oldTime = { ...asset.localDateTime };
const opResult = operation(asset);
let remove = false;
if (opResult) {
remove = (opResult as { remove: boolean }).remove ?? false;
}
const newTime = asset.localDateTime;
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime;
remove = true;
moveAssets.push({ asset, date: { year, month, day } });
}
unprocessedIds.delete(assetId);
processedIds.add(assetId);
if (remove || this.timelineManager.isExcluded(asset)) {
this.viewerAssets.splice(index, 1);
changedGeometry = true;
}
}
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
}
}

View File

@@ -13,7 +13,6 @@ export class DayGroup {
readonly monthGroup: MonthGroup;
readonly index: number;
readonly groupTitle: string;
readonly groupTitleFull: string;
readonly day: number;
viewerAssets: ViewerAsset[] = $state([]);
@@ -27,12 +26,11 @@ export class DayGroup {
#col = $state(0);
#deferredLayout = false;
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string, groupTitleFull: string) {
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) {
this.index = index;
this.monthGroup = monthGroup;
this.day = day;
this.groupTitle = groupTitle;
this.groupTitleFull = groupTitleFull;
}
get top() {
@@ -129,11 +127,7 @@ export class DayGroup {
const asset = this.viewerAssets[index].asset!;
const oldTime = { ...asset.localDateTime };
const opResult = operation(asset);
let remove = false;
if (opResult) {
remove = (opResult as { remove: boolean }).remove ?? false;
}
let { remove } = operation(asset);
const newTime = asset.localDateTime;
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime;

View File

@@ -1,23 +1,27 @@
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { TUNABLES } from '$lib/utils/tunables';
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export function updateIntersectionMonthGroup(timelineManager: PhotostreamManager, month: PhotostreamSegment) {
const actuallyIntersecting = calculateSegmentIntersecting(timelineManager, month, 0, 0);
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0);
let preIntersecting = false;
if (!actuallyIntersecting) {
preIntersecting = calculateSegmentIntersecting(
preIntersecting = calculateMonthGroupIntersecting(
timelineManager,
month,
INTERSECTION_EXPAND_TOP,
INTERSECTION_EXPAND_BOTTOM,
);
}
month.updateIntersection({ intersecting: actuallyIntersecting || preIntersecting, actuallyIntersecting });
month.intersecting = actuallyIntersecting || preIntersecting;
month.actuallyIntersecting = actuallyIntersecting;
if (preIntersecting || actuallyIntersecting) {
timelineManager.clearDeferredLayout(month);
}
}
/**
@@ -36,9 +40,9 @@ export function isIntersecting(regionTop: number, regionBottom: number, windowTo
);
}
export function calculateSegmentIntersecting(
timelineManager: PhotostreamManager,
monthGroup: PhotostreamSegment,
export function calculateMonthGroupIntersecting(
timelineManager: TimelineManager,
monthGroup: MonthGroup,
expandTop: number,
expandBottom: number,
) {
@@ -54,7 +58,7 @@ export function calculateSegmentIntersecting(
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
*/
export function calculateViewerAssetIntersecting(
timelineManager: PhotostreamManager,
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
expandTop: number = INTERSECTION_EXPAND_TOP,

View File

@@ -1,14 +1,8 @@
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import type { UpdateGeometryOptions } from '../types';
export function updateGeometry(
timelineManager: PhotostreamManager,
month: PhotostreamSegment,
options: UpdateGeometryOptions,
) {
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {
const { invalidateHeight, noDefer = false } = options;
if (invalidateHeight) {
month.isHeightActual = false;
@@ -23,7 +17,7 @@ export function updateGeometry(
}
return;
}
month.layout(noDefer);
layoutMonthGroup(timelineManager, month, noDefer);
}
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {

View File

@@ -0,0 +1,104 @@
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
import { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import type { AssetOperation, TimelineAsset } from '../types';
import { updateGeometry } from './layout-support.svelte';
import { getMonthGroupByDate } from './search-support.svelte';
export function addAssetsToMonthGroups(
timelineManager: TimelineManager,
assets: TimelineAsset[],
options: { order: AssetOrder },
) {
if (assets.length === 0) {
return;
}
const addContext = new GroupInsertionCache();
const updatedMonthGroups = new SvelteSet<MonthGroup>();
const monthCount = timelineManager.months.length;
for (const asset of assets) {
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
if (!month) {
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
month.isLoaded = true;
timelineManager.months.push(month);
}
month.addTimelineAsset(asset, addContext);
updatedMonthGroups.add(month);
}
if (timelineManager.months.length !== monthCount) {
timelineManager.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
for (const group of addContext.existingDayGroups) {
group.sortAssets(options.order);
}
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of addContext.updatedBuckets) {
month.sortDayGroups();
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
timelineManager.updateIntersections();
}
export function runAssetOperation(
timelineManager: TimelineManager,
ids: Set<string>,
operation: AssetOperation,
options: { order: AssetOrder },
) {
if (ids.size === 0) {
return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false };
}
const changedMonthGroups = new SvelteSet<MonthGroup>();
let idsToProcess = new SvelteSet(ids);
const idsProcessed = new SvelteSet<string>();
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
for (const month of timelineManager.months) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = setDifference(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonthGroups.add(month);
}
}
}
if (combinedMoveAssets.length > 0) {
addAssetsToMonthGroups(
timelineManager,
combinedMoveAssets.flat().map((a) => a.asset),
options,
);
}
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
if (changedGeometry) {
timelineManager.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}

View File

@@ -13,10 +13,10 @@ export class WebsocketSupport {
#processPendingChanges = throttle(() => {
const { add, update, remove } = this.#getPendingChangeBatches();
if (add.length > 0) {
this.#timelineManager.upsertAssets(add);
this.#timelineManager.addAssets(add);
}
if (update.length > 0) {
this.#timelineManager.upsertAssets(update);
this.#timelineManager.updateAssets(update);
}
if (remove.length > 0) {
this.#timelineManager.removeAssets(remove);

View File

@@ -1,26 +1,22 @@
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error';
import {
formatGroupTitle,
formatGroupTitleFull,
formatMonthGroupTitle,
fromTimelinePlainDate,
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
getSegmentIdentifier,
getTimes,
setDifference,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { layoutMonthGroup, updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import {
PhotostreamSegment,
type SegmentIdentifier,
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
@@ -28,49 +24,71 @@ import type { TimelineManager } from './timeline-manager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup extends PhotostreamSegment {
export class MonthGroup {
#intersecting: boolean = $state(false);
actuallyIntersecting: boolean = $state(false);
isLoaded: boolean = $state(false);
dayGroups: DayGroup[] = $state([]);
readonly timelineManager: TimelineManager;
#height: number = $state(0);
#top: number = $state(0);
#initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc;
#yearMonth: TimelineYearMonth;
#identifier: SegmentIdentifier;
#timelineManager: TimelineManager;
percent: number = $state(0);
assetsCount: number = $derived(
this.isLoaded
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
: this.#initialCount,
);
loader: CancellableTask | undefined;
isHeightActual: boolean = $state(false);
readonly monthGroupTitle: string;
readonly yearMonth: TimelineYearMonth;
constructor(
timelineManager: TimelineManager,
store: TimelineManager,
yearMonth: TimelineYearMonth,
initialCount: number,
loaded: boolean,
order: AssetOrder = AssetOrder.Desc,
) {
super();
this.initialCount = initialCount;
this.#yearMonth = yearMonth;
this.#identifier = getSegmentIdentifier(yearMonth);
this.#timelineManager = timelineManager;
this.timelineManager = store;
this.#initialCount = initialCount;
this.#sortOrder = order;
this.yearMonth = yearMonth;
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
if (loaded) {
this.markLoaded();
this.loader = new CancellableTask(
() => {
this.isLoaded = true;
},
() => {
this.dayGroups = [];
this.isLoaded = false;
},
this.#handleLoadError,
);
}
set intersecting(newValue: boolean) {
const old = this.#intersecting;
if (old === newValue) {
return;
}
this.#intersecting = newValue;
if (newValue) {
void this.timelineManager.loadMonthGroup(this.yearMonth);
} else {
this.cancel();
}
}
get identifier() {
return this.#identifier;
}
get timelineManager() {
return this.#timelineManager;
}
get yearMonth() {
return this.#yearMonth;
}
fetch(signal: AbortSignal): Promise<void> {
return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal);
get intersecting() {
return this.#intersecting;
}
get lastDayGroup() {
@@ -81,9 +99,9 @@ export class MonthGroup extends PhotostreamSegment {
return this.dayGroups[0]?.getFirstAsset();
}
get viewerAssets() {
getAssets() {
// eslint-disable-next-line unicorn/no-array-reduce
return this.dayGroups.reduce((accumulator: ViewerAsset[], g: DayGroup) => accumulator.concat(g.viewerAssets), []);
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []);
}
sortDayGroups() {
@@ -204,8 +222,7 @@ export class MonthGroup extends PhotostreamSegment {
addContext.setDayGroup(dayGroup, localDateTime);
} else {
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime));
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle, groupTitleFull);
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle);
this.dayGroups.push(dayGroup);
addContext.setDayGroup(dayGroup, localDateTime);
addContext.newDayGroups.add(dayGroup);
@@ -225,15 +242,67 @@ export class MonthGroup extends PhotostreamSegment {
return this.getRandomDayGroup()?.getRandomAsset()?.asset;
}
get id() {
return this.viewId;
}
get viewId() {
const { year, month } = this.yearMonth;
return year + '-' + month;
}
set height(height: number) {
if (this.#height === height) {
return;
}
const { timelineManager: store, percent } = this;
const index = store.months.indexOf(this);
const heightDelta = height - this.#height;
this.#height = height;
const prevMonthGroup = store.months[index - 1];
if (prevMonthGroup) {
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
const monthGroup = this.timelineManager.months[cursor];
const newTop = monthGroup.#top + heightDelta;
if (monthGroup.#top !== newTop) {
monthGroup.#top = newTop;
}
}
if (store.topIntersectingMonthGroup) {
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
if (currentIndex > 0) {
if (index < currentIndex) {
store.scrollCompensation = {
heightDelta,
scrollTop: undefined,
monthGroup: this,
};
} else if (percent > 0) {
const top = this.top + height * percent;
store.scrollCompensation = {
heightDelta: undefined,
scrollTop: top,
monthGroup: this,
};
}
}
}
}
get height() {
return this.#height;
}
get top(): number {
return this.#top + this.timelineManager.topSectionHeight;
}
#handleLoadError(error: unknown) {
const _$t = get(t);
handleError(error, _$t('errors.failed_to_load_assets'));
}
findDayGroupForAsset(asset: TimelineAsset) {
for (const group of this.dayGroups) {
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
@@ -247,7 +316,7 @@ export class MonthGroup extends PhotostreamSegment {
}
findAssetAbsolutePosition(assetId: string) {
this.#clearDeferredLayout();
this.timelineManager.clearDeferredLayout(this);
for (const group of this.dayGroups) {
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) {
@@ -305,26 +374,4 @@ export class MonthGroup extends PhotostreamSegment {
cancel() {
this.loader?.cancel();
}
layout(noDefer: boolean) {
layoutMonthGroup(this.timelineManager, this, noDefer);
}
#clearDeferredLayout() {
const hasDeferred = this.dayGroups.some((group) => group.deferredLayout);
if (hasDeferred) {
updateGeometry(this.timelineManager, this, { invalidateHeight: true, noDefer: true });
for (const group of this.dayGroups) {
group.deferredLayout = false;
}
}
}
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
this.intersecting = intersecting;
this.actuallyIntersecting = actuallyIntersecting;
if (intersecting) {
this.#clearDeferredLayout();
}
}
}

View File

@@ -1,7 +1,7 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { TimelineManager } from './timeline-manager.svelte';
@@ -92,7 +92,7 @@ describe('TimelineManager', () => {
});
});
describe('loadSegment', () => {
describe('loadMonthGroup', () => {
let timelineManager: TimelineManager;
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
@@ -128,52 +128,52 @@ describe('TimelineManager', () => {
});
it('loads a month', async () => {
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('ignores invalid months', async () => {
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 }));
await timelineManager.loadMonthGroup({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
});
it('cancels month loading', async () => {
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
void timelineManager.loadMonthGroup({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('prevents loading months multiple times', async () => {
await Promise.all([
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
});
it('allows loading a canceled month', async () => {
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
const loadPromise = timelineManager.loadMonthGroup({ year: 2024, month: 1 });
month.cancel();
await loadPromise;
expect(month?.assets.length).toEqual(0);
expect(month?.getAssets().length).toEqual(0);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(month!.assets.length).toEqual(3);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
expect(month!.getAssets().length).toEqual(3);
});
});
describe('upsertAssets', () => {
describe('addAssets', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
@@ -194,11 +194,11 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([asset]);
timelineManager.addAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].assets.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
@@ -210,12 +210,12 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.upsertAssets([assetOne]);
timelineManager.upsertAssets([assetTwo]);
timelineManager.addAssets([assetOne]);
timelineManager.addAssets([assetTwo]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months[0].assets.length).toEqual(2);
expect(timelineManager.months[0].getAssets().length).toEqual(2);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
});
@@ -236,14 +236,14 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
expect(month).not.toBeNull();
expect(month?.assets.length).toEqual(3);
expect(month?.assets[0].id).toEqual(assetOne.id);
expect(month?.assets[1].id).toEqual(assetThree.id);
expect(month?.assets[2].id).toEqual(assetTwo.id);
expect(month?.getAssets().length).toEqual(3);
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
});
it('orders months by descending date', () => {
@@ -262,7 +262,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
expect(timelineManager.months.length).toEqual(3);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
@@ -276,11 +276,11 @@ describe('TimelineManager', () => {
});
it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]);
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
timelineManager.addAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -292,7 +292,7 @@ describe('TimelineManager', () => {
const timelineManager = new TimelineManager();
await timelineManager.updateOptions({ isTrashed: true });
timelineManager.upsertAssets([asset, trashedAsset]);
timelineManager.addAssets([asset, trashedAsset]);
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
});
});
@@ -307,15 +307,22 @@ describe('TimelineManager', () => {
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
it('ignores non-existing assets', () => {
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
expect(timelineManager.months.length).toEqual(0);
expect(timelineManager.assetCount).toEqual(0);
});
it('updates an asset', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
const updatedAsset = { ...asset, isFavorite: true };
timelineManager.upsertAssets([asset]);
timelineManager.addAssets([asset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
timelineManager.upsertAssets([updatedAsset]);
timelineManager.updateAssets([updatedAsset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
});
@@ -331,17 +338,17 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
});
timelineManager.upsertAssets([asset]);
timelineManager.addAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
timelineManager.upsertAssets([updatedAsset]);
timelineManager.updateAssets([updatedAsset]);
expect(timelineManager.months.length).toEqual(2);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
});
});
@@ -356,7 +363,7 @@ describe('TimelineManager', () => {
});
it('ignores invalid IDs', () => {
timelineManager.upsertAssets(
timelineManager.addAssets(
timelineAssetFactory
.buildList(2, {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
@@ -367,7 +374,7 @@ describe('TimelineManager', () => {
expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].assets.length).toEqual(2);
expect(timelineManager.months[0].getAssets().length).toEqual(2);
});
it('removes asset from month', () => {
@@ -376,12 +383,12 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetOne.id]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].assets.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1);
});
it('does not remove month when empty', () => {
@@ -390,7 +397,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.upsertAssets(assets);
timelineManager.addAssets(assets);
timelineManager.removeAssets(assets.map((asset) => asset.id));
expect(timelineManager.assetCount).toEqual(0);
@@ -422,7 +429,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.addAssets([assetOne, assetTwo]);
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
});
});
@@ -470,45 +477,45 @@ describe('TimelineManager', () => {
});
it('returns previous assetId', async () => {
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
const a = month!.assets[0];
const b = month!.assets[1];
const a = month!.getAssets()[0];
const b = month!.getAssets()[1];
const previous = await timelineManager.getLaterAsset(b);
expect(previous).toEqual(a);
});
it('returns previous assetId spanning multiple months', async () => {
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.assets[0];
const b = previousMonth!.assets[0];
const a = month!.getAssets()[0];
const b = previousMonth!.getAssets()[0];
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
});
it('loads previous month', async () => {
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getFirstAsset();
const b = previousMonth!.getFirstAsset();
const loadSegmentSpy = vi.spyOn(month!.loader!, 'execute');
const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute');
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadSegmentSpy).toBeCalledTimes(0);
expect(loadMonthGroupSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
timelineManager.removeAssets([assetTwo.id]);
@@ -516,7 +523,7 @@ describe('TimelineManager', () => {
});
it('returns null when no more assets', async () => {
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
});
});
@@ -547,7 +554,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.addAssets([assetOne, assetTwo]);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
@@ -566,7 +573,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);

View File

@@ -3,19 +3,18 @@ import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import {
getSegmentIdentifier,
toTimelineAsset,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { clamp, debounce, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import {
addAssetsToMonthGroups,
runAssetOperation,
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
import {
findMonthGroupForAsset as findMonthGroupForAssetUtil,
findMonthGroupForDate,
@@ -25,15 +24,37 @@ import {
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { DayGroup } from './day-group.svelte';
import { isMismatched } from './internal/utils.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte';
import type { AssetDescriptor, Direction, ScrubberMonth, TimelineAsset, TimelineManagerOptions } from './types';
import type {
AssetDescriptor,
AssetOperation,
Direction,
ScrubberMonth,
TimelineAsset,
TimelineManagerLayoutOptions,
TimelineManagerOptions,
Viewport,
} from './types';
export class TimelineManager {
isInitialized = $state(false);
months: MonthGroup[] = $state([]);
topSectionHeight = $state(0);
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
export class TimelineManager extends PhotostreamManager {
albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0);
#months: MonthGroup[] = $state([]);
topIntersectingMonthGroup: MonthGroup | undefined = $state();
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
}));
initTask = new CancellableTask(
() => {
@@ -51,16 +72,121 @@ export class TimelineManager extends PhotostreamManager {
);
static #INIT_OPTIONS = {};
#viewportHeight = $state(0);
#viewportWidth = $state(0);
#scrollTop = $state(0);
#websocketSupport: WebsocketSupport | undefined;
#rowHeight = $state(235);
#headerHeight = $state(48);
#gap = $state(12);
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
get months() {
return this.#months;
#scrolling = $state(false);
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
scrollCompensation: {
heightDelta: number | undefined;
scrollTop: number | undefined;
monthGroup: MonthGroup | undefined;
} = $state({
heightDelta: 0,
scrollTop: 0,
monthGroup: undefined,
});
constructor() {}
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
let changed = false;
changed ||= this.#setHeaderHeight(headerHeight);
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight);
if (changed) {
this.refreshLayout();
}
}
get options() {
return this.#options;
#setHeaderHeight(value: number) {
if (this.#headerHeight == value) {
return false;
}
this.#headerHeight = value;
return true;
}
get headerHeight() {
return this.#headerHeight;
}
#setGap(value: number) {
if (this.#gap == value) {
return false;
}
this.#gap = value;
return true;
}
get gap() {
return this.#gap;
}
#setRowHeight(value: number) {
if (this.#rowHeight == value) {
return false;
}
this.#rowHeight = value;
return true;
}
get rowHeight() {
return this.#rowHeight;
}
set scrolling(value: boolean) {
this.#scrolling = value;
if (value) {
this.suspendTransitions = true;
this.#resetScrolling();
}
}
get scrolling() {
return this.#scrolling;
}
set suspendTransitions(value: boolean) {
this.#suspendTransitions = value;
if (value) {
this.#resetSuspendTransitions();
}
}
get suspendTransitions() {
return this.#suspendTransitions;
}
set viewportWidth(value: number) {
const changed = value !== this.#viewportWidth;
this.#viewportWidth = value;
this.suspendTransitions = true;
void this.#updateViewportGeometry(changed);
}
get viewportWidth() {
return this.#viewportWidth;
}
set viewportHeight(value: number) {
this.#viewportHeight = value;
this.#suspendTransitions = true;
void this.#updateViewportGeometry(false);
}
get viewportHeight() {
return this.#viewportHeight;
}
async *assetsIterator(options?: {
@@ -72,7 +198,7 @@ export class TimelineManager extends PhotostreamManager {
const direction = options?.direction ?? 'earlier';
let { startDayGroup, startAsset } = options ?? {};
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
await this.loadSegment(monthGroup.identifier, { cancelable: false });
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false });
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
startDayGroup = startAsset = undefined;
}
@@ -108,24 +234,75 @@ export class TimelineManager extends PhotostreamManager {
this.#websocketSupport = undefined;
}
updateSlidingWindow(scrollTop: number) {
if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop;
this.updateIntersections();
}
}
clearScrollCompensation() {
this.scrollCompensation = {
heightDelta: undefined,
scrollTop: undefined,
monthGroup: undefined,
};
}
updateIntersections() {
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return;
}
let topIntersectingMonthGroup = undefined;
for (const month of this.months) {
updateIntersectionMonthGroup(this, month);
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
topIntersectingMonthGroup = month;
}
}
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
}
for (const month of this.months) {
if (month === this.topIntersectingMonthGroup) {
this.topIntersectingMonthGroup.percent = clamp(
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
0,
1,
);
} else {
month.percent = 0;
}
}
}
clearDeferredLayout(month: MonthGroup) {
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
if (hasDeferred) {
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
for (const group of month.dayGroups) {
group.deferredLayout = false;
}
}
}
async #initializeMonthGroups() {
const timebuckets = await getTimeBuckets({
...authManager.params,
...this.#options,
});
this.#months = timebuckets.map((timeBucket) => {
this.months = timebuckets.map((timeBucket) => {
const date = new SvelteDate(timeBucket.timeBucket);
return new MonthGroup(
this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count,
false,
this.#options.order,
);
});
this.albumAssets.clear();
this.updateViewportGeometry(false);
this.#updateViewportGeometry(false);
}
async updateOptions(options: TimelineManagerOptions) {
@@ -136,16 +313,16 @@ export class TimelineManager extends PhotostreamManager {
return;
}
await this.initTask.reset();
this.#options = options;
await this.init();
this.updateViewportGeometry(false);
await this.#init(options);
this.#updateViewportGeometry(false);
}
async init() {
async #init(options: TimelineManagerOptions) {
this.isInitialized = false;
this.#months = [];
this.months = [];
this.albumAssets.clear();
await this.initTask.execute(async () => {
this.#options = options;
await this.#initializeMonthGroups();
}, true);
}
@@ -155,8 +332,36 @@ export class TimelineManager extends PhotostreamManager {
this.isInitialized = false;
}
updateViewportGeometry(changedWidth: boolean) {
super.updateViewportGeometry(changedWidth);
async updateViewport(viewport: Viewport) {
if (viewport.height === 0 && viewport.width === 0) {
return;
}
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
return;
}
if (!this.initTask.executed) {
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
}
const changedWidth = viewport.width !== this.viewportWidth;
this.viewportHeight = viewport.height;
this.viewportWidth = viewport.width;
this.#updateViewportGeometry(changedWidth);
}
#updateViewportGeometry(changedWidth: boolean) {
if (!this.isInitialized) {
return;
}
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
return;
}
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: changedWidth });
}
this.updateIntersections();
this.#createScrubberMonths();
}
@@ -171,6 +376,45 @@ export class TimelineManager extends PhotostreamManager {
this.scrubberTimelineHeight = this.timelineHeight;
}
createLayoutOptions() {
const viewportWidth = this.viewportWidth;
return {
spacing: 2,
heightTolerance: 0.15,
rowHeight: this.#rowHeight,
rowWidth: Math.floor(viewportWidth),
};
}
async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true;
if (options) {
cancelable = options.cancelable;
}
const monthGroup = getMonthGroupByDate(this, yearMonth);
if (!monthGroup) {
return;
}
if (monthGroup.loader?.executed) {
return;
}
const result = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
}, cancelable);
if (result === 'LOADED') {
updateIntersectionMonthGroup(this, monthGroup);
}
}
addAssets(assets: TimelineAsset[]) {
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.updateAssets(assetsToUpdate);
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
}
async findMonthGroupForAsset(id: string) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
@@ -198,7 +442,7 @@ export class TimelineManager extends PhotostreamManager {
}
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
await this.loadSegment(getSegmentIdentifier(yearMonth), options);
await this.loadMonthGroup(yearMonth, options);
return getMonthGroupByDate(this, yearMonth);
}
@@ -210,7 +454,7 @@ export class TimelineManager extends PhotostreamManager {
async getRandomMonthGroup() {
const random = Math.floor(Math.random() * this.months.length);
const month = this.months[random];
await this.loadSegment(getSegmentIdentifier(month.yearMonth), { cancelable: false });
await this.loadMonthGroup(month.yearMonth, { cancelable: false });
return month;
}
@@ -219,6 +463,40 @@ export class TimelineManager extends PhotostreamManager {
return month?.getRandomAsset();
}
updateAssetOperation(ids: string[], operation: AssetOperation) {
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
}
updateAssets(assets: TimelineAsset[]) {
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
const { unprocessedIds } = runAssetOperation(
this,
new SvelteSet(lookup.keys()),
(asset) => {
updateObject(asset, lookup.get(asset.id));
return { remove: false };
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
const result: TimelineAsset[] = [];
for (const id of unprocessedIds.values()) {
result.push(lookup.get(id)!);
}
return result;
}
removeAssets(ids: string[]) {
const { unprocessedIds } = runAssetOperation(
this,
new SvelteSet(ids),
() => {
return { remove: true };
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
return [...unprocessedIds];
}
refreshLayout() {
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: true });
@@ -249,7 +527,7 @@ export class TimelineManager extends PhotostreamManager {
if (!monthGroup) {
return;
}
await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false });
await this.loadMonthGroup(dateTime, { cancelable: false });
const asset = monthGroup.findClosest(dateTime);
if (asset) {
return asset;
@@ -274,42 +552,4 @@ export class TimelineManager extends PhotostreamManager {
getAssetOrder() {
return this.#options.order ?? AssetOrder.Desc;
}
protected createUpsertContext(): unknown {
return new GroupInsertionCache();
}
protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
let month = getMonthGroupByDate(this, asset.localDateTime);
if (!month) {
month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order);
this.months.push(month);
}
month.addTimelineAsset(asset, context);
}
protected postCreateSegments(): void {
this.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
protected postUpsert(context: GroupInsertionCache): void {
for (const group of context.existingDayGroups) {
group.sortAssets(this.#options.order);
}
for (const monthGroup of context.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of context.updatedBuckets) {
month.sortDayGroups();
updateGeometry(this, month, { invalidateHeight: true });
}
}
}

View File

@@ -35,7 +35,7 @@ export type TimelineAsset = {
longitude?: number | null;
};
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean } | unknown;
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };

View File

@@ -21,15 +21,15 @@ export type OnSetVisibility = (ids: string[]) => void;
export const deleteAssets = async (
force: boolean,
onAssetDelete: OnDelete | undefined,
onAssetDelete: OnDelete,
assets: TimelineAsset[],
onUndoDelete: OnUndoDelete | undefined,
onUndoDelete: OnUndoDelete | undefined = undefined,
) => {
const $t = get(t);
try {
const ids = assets.map((a) => a.id);
await deleteBulk({ assetBulkDeleteDto: { ids, force } });
onAssetDelete?.(ids);
onAssetDelete(ids);
notificationController.show({
message: force
@@ -98,5 +98,5 @@ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager,
},
);
timelineManager.upsertAssets(assets);
timelineManager.addAssets(assets);
}

View File

@@ -513,7 +513,7 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
try {
for (const monthGroup of timelineManager.months) {
await timelineManager.loadSegment(monthGroup.identifier);
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
if (!get(isSelectingAllAssets)) {
assetInteraction.clearMultiselect();

View File

@@ -1,4 +1,3 @@
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
@@ -24,11 +23,11 @@ export type TimelineDateTime = TimelineDate & {
millisecond: number;
};
export type ScrubberListener = (scrubberData: {
scrubberMonth: { year: number; month: number };
overallScrollPercent: number;
scrubberMonthScrollPercent: number;
}) => void | Promise<void>;
export type ScrubberListener = (
scrubberMonth: { year: number; month: number },
overallScrollPercent: number,
scrubberMonthScrollPercent: number,
) => void | Promise<void>;
// used for AssetResponseDto.dateTimeOriginal, amongst others
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
@@ -152,14 +151,6 @@ export function formatGroupTitle(_date: DateTime): string {
return getDateLocaleString(date, { locale: get(locale) });
}
export const formatGroupTitleFull = (_date: DateTime): string => {
if (!_date.isValid) {
return _date.toString();
}
const date = _date as DateTime<true>;
return getDateLocaleString(date);
};
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
@@ -243,11 +234,3 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
}
return result;
}
export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime) => ({
matches(segment: MonthGroup) {
return (
segment.yearMonth && segment.yearMonth.year === yearMonth.year && segment.yearMonth.month === yearMonth.month
);
},
});

View File

@@ -34,6 +34,7 @@
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
@@ -261,15 +262,18 @@
}
};
const handleSetVisibility = () => {
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
assetInteraction.clearMultiselect();
};
const handleRemoveAssets = async () => {
const handleRemoveAssets = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
await refreshAlbum();
};
const handleUndoRemoveAssets = async () => {
const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => {
timelineManager.addAssets(assets);
await refreshAlbum();
};
@@ -448,7 +452,7 @@
{isSelectionMode}
{singleSelect}
{showArchiveIcon}
onAssetSelect={onSelect}
{onSelect}
onEscape={handleEscape}
>
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
@@ -568,7 +572,14 @@
<AddToAlbum shared />
</ButtonContextMenu>
{#if assetInteraction.isAllUserOwned}
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager}></FavoriteAction>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction>
{/if}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
<DownloadAction menuItem filename="{album.albumName}.zip" />
@@ -583,7 +594,7 @@
onClick={() => updateThumbnailUsingCurrentSelection()}
/>
{/if}
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}
@@ -595,12 +606,7 @@
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
{/if}
{#if assetInteraction.isAllUserOwned}
<DeleteAssets
menuItem
onAssetDelete={handleRemoveAssets}
onUndoDelete={handleUndoRemoveAssets}
manager={timelineManager}
/>
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} onUndoDelete={handleUndoRemoveAssets} />
{/if}
</ButtonContextMenu>
</AssetSelectControlBar>

View File

@@ -65,18 +65,32 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<ArchiveAction unarchive manager={timelineManager} />
<ArchiveAction
unarchive
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager} />
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<DeleteAssets menuItem manager={timelineManager} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)} />
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View File

@@ -71,7 +71,7 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<FavoriteAction removeFavorite manager={timelineManager} />
<FavoriteAction removeFavorite onFavorite={(assetIds) => timelineManager.removeAssets(assetIds)} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
@@ -83,12 +83,20 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<DeleteAssets menuItem manager={timelineManager} />
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
/>
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View File

@@ -349,8 +349,15 @@
}
};
const handleDeleteAssets = async () => await updateAssetCount();
const handleUndoDeleteAssets = async () => await updateAssetCount();
const handleDeleteAssets = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
await updateAssetCount();
};
const handleUndoDeleteAssets = async (assets: TimelineAsset[]) => {
timelineManager.addAssets(assets);
await updateAssetCount();
};
let person = $derived(data.person);
@@ -385,7 +392,7 @@
{assetInteraction}
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
onAssetSelect={handleSelectFeaturePhoto}
onSelect={handleSelectFeaturePhoto}
onEscape={handleEscape}
>
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
@@ -504,7 +511,14 @@
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager} />
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
<MenuOption
@@ -515,16 +529,19 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
/>
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<DeleteAssets
menuItem
manager={timelineManager}
onAssetDelete={handleDeleteAssets}
onUndoDelete={handleUndoDeleteAssets}
onAssetDelete={(assetIds) => handleDeleteAssets(assetIds)}
onUndoDelete={(assets) => handleUndoDeleteAssets(assets)}
/>
</ButtonContextMenu>
</AssetSelectControlBar>

View File

@@ -70,12 +70,12 @@
const handleLink: OnLink = ({ still, motion }) => {
timelineManager.removeAssets([motion.id]);
timelineManager.upsertAssets([still]);
timelineManager.updateAssets([still]);
};
const handleUnlink: OnUnlink = ({ still, motion }) => {
timelineManager.upsertAssets([motion]);
timelineManager.upsertAssets([still]);
timelineManager.addAssets([motion]);
timelineManager.updateAssets([still]);
};
const handleSetVisibility = (assetIds: string[]) => {
@@ -118,7 +118,14 @@
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager}></FavoriteAction>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
@@ -139,11 +146,15 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem manager={timelineManager} />
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem manager={timelineManager} />
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<AssetJobActions />

View File

@@ -5,6 +5,7 @@
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
@@ -62,7 +63,7 @@
}),
);
timelineManager.upsertAssets(updatedAssets);
timelineManager.updateAssets(updatedAssets);
handleDeselectAll();
};
@@ -109,7 +110,17 @@
return !!asset.latitude && !!asset.longitude;
};
const handleAssetOpen = (asset: TimelineAsset, defaultAssetOpen: () => void) => {
const handleThumbnailClick = (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => {
if (hasGps(asset)) {
locationUpdated = true;
setTimeout(() => {
@@ -117,9 +128,9 @@
}, 1500);
location = { latitude: asset.latitude!, longitude: asset.longitude! };
void setQueryValue('at', asset.id);
return;
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
defaultAssetOpen();
};
</script>
@@ -182,9 +193,9 @@
removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape}
withStacked
onAssetOpen={handleAssetOpen}
onThumbnailClick={handleThumbnailClick}
>
{#snippet customThumbnailLayout(asset: TimelineAsset)}
{#snippet customLayout(asset: TimelineAsset)}
{#if hasGps(asset)}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
{asset.city || $t('gps')}