Compare commits

...

24 Commits

Author SHA1 Message Date
midzelis
ed63399181 refactor(web): extract timeline selection logic into SelectableSegment and SelectableDay components
- Move asset selection, range selection, and keyboard interaction logic
  to SelectableSegment
  - Extract day group selection logic to SelectableDay component
  - Simplify Timeline component by removing selection-related state and
  handlers
  - Fix scroll compensation handling with dedicated while loop
  - Remove unused keyboard handlers from Scrubber component
2025-09-30 00:08:14 +00:00
midzelis
0168353c2d refactor(web): extract asset grid layout logic into AssetLayout component
- Extracts the asset grid rendering logic from `MonthSegment` into a
  dedicated `AssetLayout` component
- Simplifies `MonthSegment` by delegating layout responsibilities
  while maintaining all existing functionality
- Renames `customLayout` prop to `customThumbnailLayout` for clarity
  across Timeline components


## Changes
  - Created new `AssetLayout.svelte` component that handles:
    - Asset grid rendering with proper positioning
    - Animation transitions
    - Filtering of intersecting viewer assets
  - Updated `MonthSegment.svelte` to use `AssetLayout` via composition
  pattern
  - Renamed `customLayout` to `customThumbnailLayout` in Timeline and
  related components
  - Moved thumbnail click and selection logic to Timeline parent
  component using snippets
2025-09-30 00:08:14 +00:00
midzelis
c44b315117 refactor(web): consolidate asset operations in PhotostreamManager base class
Moves common asset operation methods (upsertAssets, removeAssets, 
updateAssetOperation) from TimelineManager into PhotostreamManager 
base class, making them available to all photostream implementations. 
Updates all consuming components to use the more accurate 'upsertAssets' 
naming instead of separate 'addAssets' and 'updateAssets' methods.

- Move asset operation methods to PhotostreamManager base class
- Replace addAssets/updateAssets calls with unified upsertAssets method
- Update type imports to use PhotostreamManager instead of TimelineManager
- Remove operations-support.svelte.ts (functionality moved to base class)
- Add abstract upsertAssetIntoSegment method for subclass customization
2025-09-30 00:00:50 +00:00
midzelis
98ab224791 refactor: adjust favorite, delete, and archive actions for timeline
- Pass TimelineManager instance to timeline action components
  instead of callbacks
- Move asset update logic (delete, archive, favorite) into
  action components
2025-09-29 01:26:59 +00:00
midzelis
e5fce47c0c Rename TimelineDateGroup to MonthSegment 2025-09-28 19:41:41 +00:00
midzelis
3a468a3f50 refactor(web): extract common timeline functionality into PhotostreamManager base classes
Create abstract PhotostreamManager and PhotostreamSegment base classes to enable reusable      
timeline-like components. This refactoring extracts common viewport management, scroll         
handling, and segment operations from TimelineManager and MonthGroup into reusable             
abstractions.                                                                                  
                                                                                                  
Changes:                                                                                       
 - Add PhotostreamManager.svelte.ts with viewport and scroll management                         
 - Add PhotostreamSegment.svelte.ts with segment positioning and intersection logic             
 - Refactor TimelineManager to extend PhotostreamManager                                        
 - Refactor MonthGroup to extend PhotostreamSegment                                             
 - Add utility functions for segment identification and date formatting                         
 - Update tests to reflect new inheritance structure
2025-09-28 19:41:41 +00:00
midzelis
e600cf64b0 refactor(web): extract asset viewer logic from Timeline into TimelineAssetViewer component
- Extracted asset viewer navigation and action handling logic from Timeline.svelte into a dedicated TimelineAssetViewer component
- Reduces Timeline.svelte complexity by ~150 lines and improves separation of concerns
- No functional changes - purely a refactoring to improve code organization

## Changes
- Created new TimelineAssetViewer.svelte component containing all asset viewer-related logic
- Moved handlePrevious, handleNext, handleRandom, handleClose, handlePreAction, and handleAction methods
- Timeline.svelte now only passes required props to the new component
- Maintained all existing functionality including navigation, asset actions, and stack management
2025-09-28 19:41:41 +00:00
midzelis
41066b1c31 refactor(web): extract timeline keyboard actions into separate component
Extracts keyboard shortcuts and related functionality from Timeline component into a dedicated TimelineKeyboardActions component for better separation of concerns and maintainability.
2025-09-28 19:41:41 +00:00
midzelis
9051fa6949 refactor(web): Clarify property names in Timeline and Scrubber
Renamed properties across Timeline/Scrubber components for clarity:
  - scrubOverallPercent → timelineScrollPercent
  - scrubberMonthPercent → viewportTopMonthScrollPercent
  - scrubberMonth → viewportTopMonth
  - leadout → isInLeadOutSection

  Additional changes:
  - Updated ScrubberListener signature to accept object parameter
  - Added detailed JSDoc comments for all Scrubber props
  - Fixed callback invocations to use new object syntax
  - Aligned Timeline's local state variables with Scrubber prop names
2025-09-28 19:41:41 +00:00
shenlong
bea116e1b9 fix: prefer remote images in new timeline (#22452)
fix: prefer remote images in new thumbnail

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-27 21:29:28 -05:00
shenlong
cdbe1d7f10 chore: show download button for remote only assets (#22453)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-27 21:28:07 -05:00
Brandon Wees
df469cc412 feat: show motion photo icon on mobile timeline tile (#22454)
* feat: show motion photo icon on timeline tile

* chore: switch to private widget for asset type icons

* chore: small cleanup on asset type icons widget
2025-09-27 21:27:34 -05:00
shenlong
8de7eed940 feat(mobile): add unstack button (#21869)
* fix: add unstack button

* feat: allow unstacking inside of asset viewer

* chore: update tests

* chore: rework unstacking in asset viewer

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: bwees <brandonwees@gmail.com>
2025-09-28 06:51:38 +05:30
shenlong
7d8cd05bc2 fix: remote album timeline filter (#22423)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 17:35:46 +00:00
Brandon Wees
30a378c580 fix: local assets should not be added to album (#22304) 2025-09-26 22:41:12 +05:30
renovate[bot]
8a3684c127 chore(deps): update ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 docker digest to 41eacbe (#22305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 14:26:55 +02:00
renovate[bot]
61e5c6349c chore(deps): update github-actions (#22311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 14:26:47 +02:00
Mert
3bcb4b7af7 fix(mobile): scrubbing mode on scroll to date event (#22390) 2025-09-25 19:20:42 -05:00
Mert
5116b215a2 fix(mobile): load local thumbnails in album timeline (#22329)
* join local asset in album query

* missed one

* formatting
2025-09-26 00:38:19 +05:30
shenlong
c5fbbee8f6 chore: update android background worker notification text (#22347)
chore: update android bg notification text

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 00:22:17 +05:30
shenlong
d73aabc494 chore: log mobile upload failures (#22349)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 00:22:03 +05:30
shenlong
b62feb726b fix: delete temp file on iOS after upload (#22364)
fix: delete temp files on iOS after upload

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 00:21:25 +05:30
Brandon Wees
972e9cc039 fix: map attribution and other styling (#22303)
* chore: map widget and page styling

* fix: map bottom sheet styling

* fix: attribution location on android

it appears that on android, the attribution marker is positioned from the top of the display and on iOS its positioned from the safe area edge
2025-09-26 00:08:25 +05:30
shenlong
ee49136e97 chore: deprecate old timeline (#22328)
* chore: deprecate old timeline

* change trigger and duration

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 00:06:17 +05:30
72 changed files with 2299 additions and 1769 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@
"add_to_album": "Add to album", "add_to_album": "Add to album",
"add_to_album_bottom_sheet_added": "Added 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_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_album_toggle": "Toggle selection for {album}",
"add_to_albums": "Add to albums", "add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})", "add_to_albums_count": "Add to albums ({count})",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
isArchived: isArchived, isArchived: isArchived,
isTrashEnabled: isTrashEnable, isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView, isInLockedView: isInLockedView,
isStacked: asset.hasRemote && (asset as RemoteAsset).stackId != null,
currentAlbum: currentAlbum, currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting, advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer, 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 isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final actions = <Widget>[ final actions = <Widget>[
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true), if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared) if (album != null && album.isActivityEnabled && album.isShared)
IconButton( IconButton(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ 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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_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/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/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -39,14 +38,7 @@ class Thumbnail extends StatefulWidget {
), ),
_ => null, _ => null,
}, },
imageProvider = switch (asset) { imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size);
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 @override
State<Thumbnail> createState() => _ThumbnailState(); State<Thumbnail> createState() => _ThumbnailState();

View File

@@ -54,8 +54,6 @@ class ThumbnailTile extends ConsumerWidget {
) )
: const BoxDecoration(); : const BoxDecoration();
final hasStack = asset is RemoteAsset && asset.stackId != null;
final bool storageIndicator = final bool storageIndicator =
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))); showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
@@ -77,21 +75,10 @@ class ThumbnailTile extends ConsumerWidget {
child: Thumbnail.fromAsset(asset: asset, size: size), child: Thumbnail.fromAsset(asset: asset, size: size),
), ),
), ),
if (hasStack) if (asset != null)
Align( Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
child: Padding( child: _AssetTypeIcons(asset: asset),
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) if (storageIndicator && asset != null)
switch (asset.storage) { switch (asset.storage) {
@@ -214,3 +201,34 @@ 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,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
@@ -9,8 +10,8 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_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/bottom_sheet/map_bottom_sheet.widget.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/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -187,6 +188,8 @@ class _Map extends StatelessWidget {
styleString: style, styleString: style,
onMapCreated: onMapCreated, onMapCreated: onMapCreated,
onStyleLoadedCallback: onMapReady, onStyleLoadedCallback: onMapReady,
attributionButtonPosition: AttributionButtonPosition.topRight,
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
), ),
), ),
); );

View File

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

View File

@@ -1,6 +1,5 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first // ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@@ -12,8 +11,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
class EnqueueStatus { class EnqueueStatus {
final int enqueueCount; final int enqueueCount;
@@ -90,33 +89,6 @@ class DriftUploadStatus {
networkSpeedAsString.hashCode ^ networkSpeedAsString.hashCode ^
isFailed.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 { class DriftBackupState {
@@ -267,6 +239,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
} }
state = state.copyWith(uploadItems: {...state.uploadItems, taskId: currentItem.copyWith(isFailed: true)}); state = state.copyWith(uploadItems: {...state.uploadItems, taskId: currentItem.copyWith(isFailed: true)});
_logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
break; break;
case TaskStatus.canceled: case TaskStatus.canceled:

View File

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

View File

@@ -28,6 +28,8 @@ class MultiSelectState {
bool get hasRemote => bool get hasRemote =>
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged); 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 hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged); bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.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/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
@@ -19,9 +20,9 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.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:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:immich_mobile/utils/debug_print.dart';
final uploadServiceProvider = Provider((ref) { final uploadServiceProvider = Provider((ref) {
final service = UploadService( final service = UploadService(
@@ -205,10 +206,20 @@ class UploadService {
return _uploadRepository.start(); return _uploadRepository.start();
} }
void _handleTaskStatusUpdate(TaskStatusUpdate update) { void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
switch (update.status) { switch (update.status) {
case TaskStatus.complete: case TaskStatus.complete:
_handleLivePhoto(update); _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; break;
default: default:

View File

@@ -16,6 +16,7 @@ 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/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/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/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/action_buttons/upload_action_button.widget.dart';
class ActionButtonContext { class ActionButtonContext {
@@ -24,6 +25,7 @@ class ActionButtonContext {
final bool isArchived; final bool isArchived;
final bool isTrashEnabled; final bool isTrashEnabled;
final bool isInLockedView; final bool isInLockedView;
final bool isStacked;
final RemoteAlbum? currentAlbum; final RemoteAlbum? currentAlbum;
final bool advancedTroubleshooting; final bool advancedTroubleshooting;
final ActionSource source; final ActionSource source;
@@ -33,6 +35,7 @@ class ActionButtonContext {
required this.isOwner, required this.isOwner,
required this.isArchived, required this.isArchived,
required this.isTrashEnabled, required this.isTrashEnabled,
required this.isStacked,
required this.isInLockedView, required this.isInLockedView,
required this.currentAlbum, required this.currentAlbum,
required this.advancedTroubleshooting, required this.advancedTroubleshooting,
@@ -55,6 +58,7 @@ enum ActionButtonType {
deleteLocal, deleteLocal,
upload, upload,
removeFromAlbum, removeFromAlbum,
unstack,
likeActivity; likeActivity;
bool shouldShow(ActionButtonContext context) { bool shouldShow(ActionButtonContext context) {
@@ -110,6 +114,10 @@ enum ActionButtonType {
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
context.currentAlbum != null, context.currentAlbum != null,
ActionButtonType.unstack =>
context.isOwner && //
!context.isInLockedView && //
context.isStacked,
ActionButtonType.likeActivity => ActionButtonType.likeActivity =>
!context.isInLockedView && !context.isInLockedView &&
context.currentAlbum != null && context.currentAlbum != null &&
@@ -138,28 +146,13 @@ enum ActionButtonType {
source: context.source, source: context.source,
), ),
ActionButtonType.likeActivity => const LikeActivityActionButton(), ActionButtonType.likeActivity => const LikeActivityActionButton(),
ActionButtonType.unstack => UnStackActionButton(source: context.source),
}; };
} }
} }
class ActionButtonBuilder { class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = [ static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
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) { static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();

View File

@@ -129,19 +129,24 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
title: Builder( title: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Builder( Padding(
builder: (context) { padding: const EdgeInsets.only(top: 3.0),
return Padding( child: SvgPicture.asset(
padding: const EdgeInsets.only(top: 3.0), context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
child: SvgPicture.asset( height: 40,
context.isDarkTheme ),
? 'assets/immich-logo-inline-dark.svg' ),
: 'assets/immich-logo-inline-light.svg', const Tooltip(
height: 40, 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),
),
), ),
], ],
); );

View File

@@ -82,6 +82,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -112,6 +113,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -127,6 +129,7 @@ void main() {
isInLockedView: true, isInLockedView: true,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -145,6 +148,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -161,6 +165,7 @@ void main() {
isInLockedView: true, isInLockedView: true,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -177,6 +182,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -195,6 +201,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -211,6 +218,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -227,6 +235,7 @@ void main() {
isInLockedView: true, isInLockedView: true,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -243,6 +252,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -259,6 +269,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -277,6 +288,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -293,6 +305,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -309,6 +322,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -327,6 +341,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -343,6 +358,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -359,6 +375,7 @@ void main() {
isInLockedView: true, isInLockedView: true,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -377,6 +394,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -393,6 +411,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -411,6 +430,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -427,6 +447,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -445,6 +466,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -463,6 +485,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -481,6 +504,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -497,6 +521,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -512,6 +537,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -530,6 +556,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -548,6 +575,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -563,6 +591,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -581,6 +610,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -597,6 +627,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -613,6 +644,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -628,6 +660,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -645,6 +678,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: true, advancedTroubleshooting: true,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -660,6 +694,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -668,6 +703,59 @@ 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', () { group('ActionButtonType.buildButton', () {
late BaseAsset asset; late BaseAsset asset;
late ActionButtonContext context; late ActionButtonContext context;
@@ -682,6 +770,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
}); });
@@ -698,6 +787,22 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, 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, source: ActionSource.timeline,
); );
final widget = buttonType.buildButton(contextWithAlbum); final widget = buttonType.buildButton(contextWithAlbum);
@@ -721,6 +826,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -741,6 +847,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -759,6 +866,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -778,6 +886,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -791,6 +900,7 @@ void main() {
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );

View File

@@ -161,7 +161,24 @@ export class NotificationService extends BaseService {
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]); const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
if (asset) { if (asset) {
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(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,
},
},
}),
);
} }
} }

View File

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

View File

@@ -0,0 +1,68 @@
<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

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

View File

@@ -0,0 +1,62 @@
<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

@@ -0,0 +1,208 @@
<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,46 +1,35 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
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 Scrubber from '$lib/components/timeline/Scrubber.svelte';
import { import SelectableDay from '$lib/components/timeline/SelectableDay.svelte';
setFocusToAsset as setFocusAssetInit, import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
setFocusTo as setFocusToInit, import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
} from '$lib/components/timeline/actions/focus-actions'; import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.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 type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; 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 type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; 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 { 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 { navigate } from '$lib/utils/navigation';
import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; import {
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; getSegmentIdentifier,
import { modalManager } from '@immich/ui'; getTimes,
type ScrubberListener,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite'; import type { UpdatePayload } from 'vite';
import DeleteAssetDialog from '../photos-page/delete-asset-dialog.svelte';
import TimelineDateGroup from './TimelineDateGroup.svelte';
interface Props { interface Props {
isSelectionMode?: boolean; isSelectionMode?: boolean;
@@ -64,22 +53,12 @@
album?: AlbumResponseDto | null; album?: AlbumResponseDto | null;
person?: PersonResponseDto | null; person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void; onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void; onEscape?: () => void;
children?: Snippet; children?: Snippet;
empty?: Snippet; empty?: Snippet;
customLayout?: Snippet<[TimelineAsset]>; customThumbnailLayout?: Snippet<[TimelineAsset]>;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
} }
let { let {
@@ -95,30 +74,36 @@
album = null, album = null,
person = null, person = null,
isShowDeleteConfirmation = $bindable(false), isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
onAssetSelect,
onAssetOpen,
onEscape = () => {}, onEscape = () => {},
children, children,
empty, empty,
customLayout, customThumbnailLayout,
onThumbnailClick,
}: Props = $props(); }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore; let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let element: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state();
let showSkeleton = $state(true); let showSkeleton = $state(true);
let isShowSelectDate = $state(false); // The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
let scrubberMonthPercent = $state(0); // Note: There may be multiple months visible within the viewport at any given time.
let scrubberMonth: { year: number; month: number } | undefined = $state(undefined); let viewportTopMonthScrollPercent = $state(0);
let scrubOverallPercent: number = $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 scrubberWidth = $state(0); let scrubberWidth = $state(0);
// 60 is the bottom spacer element at 60px // 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60; let bottomSectionHeight = 60;
let leadout = $state(false); // Indicates whether the viewport is currently in the lead-out section (after all months)
let isInLeadOutSection = $state(false);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mobileDevice.maxMd); const maxMd = $derived(mobileDevice.maxMd);
const usingMobileDevice = $derived(mobileDevice.pointerCoarse); const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
@@ -154,14 +139,26 @@
scrollTo(0); 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) => { const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
// the following method may trigger any layouts, so need to // the following method may trigger any layouts, so need to
// handle any scroll compensation that may have been set // handle any scroll compensation that may have been set
const height = monthGroup!.findAssetAbsolutePosition(assetId); 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) { while (timelineManager.scrollCompensation.monthGroup) {
handleScrollCompensation(timelineManager.scrollCompensation); handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
timelineManager.clearScrollCompensation();
} }
return height; return height;
}; };
@@ -257,19 +254,6 @@
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker // note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0); 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); const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => { onMount(() => {
@@ -301,20 +285,19 @@
scrollTop(scrollToTop); scrollTop(scrollToTop);
}; };
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker // note: don't throttle, debounce, or otherwise make this function async - it causes flicker
const onScrub: ScrubberListener = ( // this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
scrubMonth: { year: number; month: number }, const onScrub: ScrubberListener = (scrubberData) => {
overallScrollPercent: number, const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
scrubberMonthScrollPercent: number,
) => { if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
if (!scrubMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
const offset = maxScroll * overallScrollPercent; const offset = maxScroll * overallScrollPercent;
scrollTop(offset); scrollTop(offset);
} else { } else {
const monthGroup = timelineManager.months.find( const monthGroup = timelineManager.months.find(
({ yearMonth: { year, month } }) => year === scrubMonth.year && month === scrubMonth.month, ({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
); );
if (!monthGroup) { if (!monthGroup) {
return; return;
@@ -325,7 +308,7 @@
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker // note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const handleTimelineScroll = () => { const handleTimelineScroll = () => {
leadout = false; isInLeadOutSection = false;
if (!element) { if (!element) {
return; return;
@@ -334,19 +317,19 @@
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
scrubberMonth = undefined; viewportTopMonth = undefined;
scrubberMonthPercent = 0; viewportTopMonthScrollPercent = 0;
} else { } else {
let top = element.scrollTop; let top = element.scrollTop;
if (top < timelineManager.topSectionHeight) { if (top < timelineManager.topSectionHeight) {
// in the lead-in area // in the lead-in area
scrubberMonth = undefined; viewportTopMonth = undefined;
scrubberMonthPercent = 0; viewportTopMonthScrollPercent = 0;
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
return; return;
} }
@@ -371,15 +354,15 @@
let next = top - monthGroupHeight * maxScrollPercent; let next = top - monthGroupHeight * maxScrollPercent;
// instead of checking for < 0, add a little wiggle room for subpixel resolution // instead of checking for < 0, add a little wiggle room for subpixel resolution
if (next < -1 && monthGroup) { if (next < -1 && monthGroup) {
scrubberMonth = monthGroup; viewportTopMonth = monthGroup;
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage // allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
scrubberMonthPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent)); viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
// compensate for lost precision/rounding errors advance to the next bucket, if present // compensate for lost precision/rounding errors advance to the next bucket, if present
if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) { if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
scrubberMonth = timelineManager.months[i + 1].yearMonth; viewportTopMonth = timelineManager.months[i + 1].yearMonth;
scrubberMonthPercent = 0; viewportTopMonthScrollPercent = 0;
} }
found = true; found = true;
@@ -388,465 +371,31 @@
top = next; top = next;
} }
if (!found) { if (!found) {
leadout = true; isInLeadOutSection = true;
scrubberMonth = undefined; viewportTopMonth = undefined;
scrubberMonthPercent = 0; viewportTopMonthScrollPercent = 0;
scrubOverallPercent = 1; timelineScrollPercent = 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(() => { $effect(() => {
if ($showAssetViewer) { if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60); const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month }); void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
} }
}); });
</script> </script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} /> <HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
{#if isShowDeleteConfirmation} <TimelineKeyboardActions
<DeleteAssetDialog scrollToAsset={(asset) => scrollToAsset(asset) ?? false}
size={idsSelectedAssets.length} {timelineManager}
onCancel={() => (isShowDeleteConfirmation = false)} {assetInteraction}
onConfirm={() => handlePromiseError(trashOrDelete(true))} bind:isShowDeleteConfirmation
/> {onEscape}
{/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} {#if timelineManager.months.length > 0}
<Scrubber <Scrubber
@@ -854,27 +403,12 @@
height={timelineManager.viewportHeight} height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight} timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={bottomSectionHeight} timelineBottomOffset={bottomSectionHeight}
{leadout} {isInLeadOutSection}
{scrubOverallPercent} {timelineScrollPercent}
{scrubberMonthPercent} {viewportTopMonthScrollPercent}
{scrubberMonth} {viewportTopMonth}
{onScrub} {onScrub}
bind:scrubberWidth 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} {/if}
@@ -933,21 +467,58 @@
style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%" style:width="100%"
> >
<TimelineDateGroup <SelectableSegment
{withStacked} segment={monthGroup}
{showArchiveIcon} onScrollCompensationMonthInDOM={handleTriggeredScrollCompensation}
{assetInteraction}
{timelineManager} {timelineManager}
{assetInteraction}
{isSelectionMode} {isSelectionMode}
{singleSelect} {singleSelect}
{monthGroup} {onAssetOpen}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)} {onAssetSelect}
onSelectAssetCandidates={handleSelectAssetCandidates} >
onSelectAssets={handleSelectAssets} {#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
onScrollCompensation={handleScrollCompensation} <SelectableDay {assetInteraction} {onAssetSelect}>
{customLayout} {#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
{onThumbnailClick} <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>
</div> </div>
{/if} {/if}
{/each} {/each}
@@ -964,22 +535,7 @@
<Portal target="body"> <Portal target="body">
{#if $showAssetViewer} {#if $showAssetViewer}
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} <TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/await}
{/if} {/if}
</Portal> </Portal>

View File

@@ -0,0 +1,177 @@
<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

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

View File

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

View File

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

View File

@@ -0,0 +1,221 @@
<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

@@ -0,0 +1,431 @@
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

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

View File

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

View File

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

View File

@@ -1,104 +0,0 @@
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(() => { #processPendingChanges = throttle(() => {
const { add, update, remove } = this.#getPendingChangeBatches(); const { add, update, remove } = this.#getPendingChangeBatches();
if (add.length > 0) { if (add.length > 0) {
this.#timelineManager.addAssets(add); this.#timelineManager.upsertAssets(add);
} }
if (update.length > 0) { if (update.length > 0) {
this.#timelineManager.updateAssets(update); this.#timelineManager.upsertAssets(update);
} }
if (remove.length > 0) { if (remove.length > 0) {
this.#timelineManager.removeAssets(remove); this.#timelineManager.removeAssets(remove);

View File

@@ -1,22 +1,26 @@
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error';
import { import {
formatGroupTitle, formatGroupTitle,
formatGroupTitleFull,
formatMonthGroupTitle, formatMonthGroupTitle,
fromTimelinePlainDate, fromTimelinePlainDate,
fromTimelinePlainDateTime, fromTimelinePlainDateTime,
fromTimelinePlainYearMonth, fromTimelinePlainYearMonth,
getSegmentIdentifier,
getTimes, getTimes,
setDifference, setDifference,
type TimelineDateTime, type TimelineDateTime,
type TimelineYearMonth, type TimelineYearMonth,
} from '$lib/utils/timeline-util'; } from '$lib/utils/timeline-util';
import { t } from 'svelte-i18n'; import { layoutMonthGroup, updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { get } from 'svelte/store'; import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import {
PhotostreamSegment,
type SegmentIdentifier,
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte'; import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte';
@@ -24,71 +28,49 @@ import type { TimelineManager } from './timeline-manager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types'; import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup { export class MonthGroup extends PhotostreamSegment {
#intersecting: boolean = $state(false);
actuallyIntersecting: boolean = $state(false);
isLoaded: boolean = $state(false);
dayGroups: DayGroup[] = $state([]); dayGroups: DayGroup[] = $state([]);
readonly timelineManager: TimelineManager;
#height: number = $state(0);
#top: number = $state(0);
#initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc; #sortOrder: AssetOrder = AssetOrder.Desc;
percent: number = $state(0); #yearMonth: TimelineYearMonth;
#identifier: SegmentIdentifier;
assetsCount: number = $derived( #timelineManager: TimelineManager;
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 monthGroupTitle: string;
readonly yearMonth: TimelineYearMonth;
constructor( constructor(
store: TimelineManager, timelineManager: TimelineManager,
yearMonth: TimelineYearMonth, yearMonth: TimelineYearMonth,
initialCount: number, initialCount: number,
loaded: boolean,
order: AssetOrder = AssetOrder.Desc, order: AssetOrder = AssetOrder.Desc,
) { ) {
this.timelineManager = store; super();
this.#initialCount = initialCount; this.initialCount = initialCount;
this.#yearMonth = yearMonth;
this.#identifier = getSegmentIdentifier(yearMonth);
this.#timelineManager = timelineManager;
this.#sortOrder = order; this.#sortOrder = order;
this.yearMonth = yearMonth;
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth)); this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
if (loaded) {
this.loader = new CancellableTask( this.markLoaded();
() => {
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 intersecting() { get identifier() {
return this.#intersecting; 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 lastDayGroup() { get lastDayGroup() {
@@ -99,9 +81,9 @@ export class MonthGroup {
return this.dayGroups[0]?.getFirstAsset(); return this.dayGroups[0]?.getFirstAsset();
} }
getAssets() { get viewerAssets() {
// eslint-disable-next-line unicorn/no-array-reduce // eslint-disable-next-line unicorn/no-array-reduce
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []); return this.dayGroups.reduce((accumulator: ViewerAsset[], g: DayGroup) => accumulator.concat(g.viewerAssets), []);
} }
sortDayGroups() { sortDayGroups() {
@@ -222,7 +204,8 @@ export class MonthGroup {
addContext.setDayGroup(dayGroup, localDateTime); addContext.setDayGroup(dayGroup, localDateTime);
} else { } else {
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime)); const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle); const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime));
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle, groupTitleFull);
this.dayGroups.push(dayGroup); this.dayGroups.push(dayGroup);
addContext.setDayGroup(dayGroup, localDateTime); addContext.setDayGroup(dayGroup, localDateTime);
addContext.newDayGroups.add(dayGroup); addContext.newDayGroups.add(dayGroup);
@@ -242,67 +225,15 @@ export class MonthGroup {
return this.getRandomDayGroup()?.getRandomAsset()?.asset; return this.getRandomDayGroup()?.getRandomAsset()?.asset;
} }
get id() {
return this.viewId;
}
get viewId() { get viewId() {
const { year, month } = this.yearMonth; const { year, month } = this.yearMonth;
return year + '-' + month; 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) { findDayGroupForAsset(asset: TimelineAsset) {
for (const group of this.dayGroups) { for (const group of this.dayGroups) {
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) { if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
@@ -316,7 +247,7 @@ export class MonthGroup {
} }
findAssetAbsolutePosition(assetId: string) { findAssetAbsolutePosition(assetId: string) {
this.timelineManager.clearDeferredLayout(this); this.#clearDeferredLayout();
for (const group of this.dayGroups) { for (const group of this.dayGroups) {
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId); const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) { if (viewerAsset) {
@@ -374,4 +305,26 @@ export class MonthGroup {
cancel() { cancel() {
this.loader?.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 { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { AbortError } from '$lib/utils'; import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { TimelineManager } from './timeline-manager.svelte'; import { TimelineManager } from './timeline-manager.svelte';
@@ -92,7 +92,7 @@ describe('TimelineManager', () => {
}); });
}); });
describe('loadMonthGroup', () => { describe('loadSegment', () => {
let timelineManager: TimelineManager; let timelineManager: TimelineManager;
const bucketAssets: Record<string, TimelineAsset[]> = { const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) => '2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
@@ -128,52 +128,52 @@ describe('TimelineManager', () => {
}); });
it('loads a month', async () => { it('loads a month', async () => {
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
}); });
it('ignores invalid months', async () => { it('ignores invalid months', async () => {
await timelineManager.loadMonthGroup({ year: 2023, month: 1 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(0); expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
}); });
it('cancels month loading', async () => { it('cancels month loading', async () => {
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!; const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
void timelineManager.loadMonthGroup({ year: 2024, month: 1 }); void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort'); const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel(); month?.cancel();
expect(abortSpy).toBeCalledTimes(1); expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
}); });
it('prevents loading months multiple times', async () => { it('prevents loading months multiple times', async () => {
await Promise.all([ await Promise.all([
timelineManager.loadMonthGroup({ year: 2024, month: 1 }), timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
timelineManager.loadMonthGroup({ year: 2024, month: 1 }), timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
]); ]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
}); });
it('allows loading a canceled month', async () => { it('allows loading a canceled month', async () => {
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!; const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
const loadPromise = timelineManager.loadMonthGroup({ year: 2024, month: 1 }); const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
month.cancel(); month.cancel();
await loadPromise; await loadPromise;
expect(month?.getAssets().length).toEqual(0); expect(month?.assets.length).toEqual(0);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(month!.getAssets().length).toEqual(3); expect(month!.assets.length).toEqual(3);
}); });
}); });
describe('addAssets', () => { describe('upsertAssets', () => {
let timelineManager: TimelineManager; let timelineManager: TimelineManager;
beforeEach(async () => { beforeEach(async () => {
@@ -194,11 +194,11 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}), }),
); );
timelineManager.addAssets([asset]); timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1); expect(timelineManager.months[0].assets.length).toEqual(1);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024); expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1); expect(timelineManager.months[0].yearMonth.month).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id); expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
@@ -210,12 +210,12 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}) })
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)); .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.addAssets([assetOne]); timelineManager.upsertAssets([assetOne]);
timelineManager.addAssets([assetTwo]); timelineManager.upsertAssets([assetTwo]);
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(2); expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months[0].getAssets().length).toEqual(2); expect(timelineManager.months[0].assets.length).toEqual(2);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024); expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1); expect(timelineManager.months[0].yearMonth.month).toEqual(1);
}); });
@@ -236,14 +236,14 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
}), }),
); );
timelineManager.addAssets([assetOne, assetTwo, assetThree]); timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 }); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
expect(month).not.toBeNull(); expect(month).not.toBeNull();
expect(month?.getAssets().length).toEqual(3); expect(month?.assets.length).toEqual(3);
expect(month?.getAssets()[0].id).toEqual(assetOne.id); expect(month?.assets[0].id).toEqual(assetOne.id);
expect(month?.getAssets()[1].id).toEqual(assetThree.id); expect(month?.assets[1].id).toEqual(assetThree.id);
expect(month?.getAssets()[2].id).toEqual(assetTwo.id); expect(month?.assets[2].id).toEqual(assetTwo.id);
}); });
it('orders months by descending date', () => { it('orders months by descending date', () => {
@@ -262,7 +262,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
}), }),
); );
timelineManager.addAssets([assetOne, assetTwo, assetThree]); timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
expect(timelineManager.months.length).toEqual(3); expect(timelineManager.months.length).toEqual(3);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024); expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
@@ -276,11 +276,11 @@ describe('TimelineManager', () => {
}); });
it('updates existing asset', () => { it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets'); const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build()); const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.addAssets([asset]); timelineManager.upsertAssets([asset]);
timelineManager.addAssets([asset]); timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]); expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.assetCount).toEqual(1);
}); });
@@ -292,7 +292,7 @@ describe('TimelineManager', () => {
const timelineManager = new TimelineManager(); const timelineManager = new TimelineManager();
await timelineManager.updateOptions({ isTrashed: true }); await timelineManager.updateOptions({ isTrashed: true });
timelineManager.addAssets([asset, trashedAsset]); timelineManager.upsertAssets([asset, trashedAsset]);
expect(await getAssets(timelineManager)).toEqual([trashedAsset]); expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
}); });
}); });
@@ -307,22 +307,15 @@ describe('TimelineManager', () => {
await timelineManager.updateViewport({ width: 1588, height: 1000 }); 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', () => { it('updates an asset', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false })); const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
const updatedAsset = { ...asset, isFavorite: true }; const updatedAsset = { ...asset, isFavorite: true };
timelineManager.addAssets([asset]); timelineManager.upsertAssets([asset]);
expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false); expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
timelineManager.updateAssets([updatedAsset]); timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true); expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
}); });
@@ -338,17 +331,17 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
}); });
timelineManager.addAssets([asset]); timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1);
timelineManager.updateAssets([updatedAsset]); timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.months.length).toEqual(2); expect(timelineManager.months.length).toEqual(2);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1);
}); });
}); });
@@ -363,7 +356,7 @@ describe('TimelineManager', () => {
}); });
it('ignores invalid IDs', () => { it('ignores invalid IDs', () => {
timelineManager.addAssets( timelineManager.upsertAssets(
timelineAssetFactory timelineAssetFactory
.buildList(2, { .buildList(2, {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
@@ -374,7 +367,7 @@ describe('TimelineManager', () => {
expect(timelineManager.assetCount).toEqual(2); expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(2); expect(timelineManager.months[0].assets.length).toEqual(2);
}); });
it('removes asset from month', () => { it('removes asset from month', () => {
@@ -383,12 +376,12 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}) })
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)); .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.addAssets([assetOne, assetTwo]); timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetOne.id]); timelineManager.removeAssets([assetOne.id]);
expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1); expect(timelineManager.months[0].assets.length).toEqual(1);
}); });
it('does not remove month when empty', () => { it('does not remove month when empty', () => {
@@ -397,7 +390,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}) })
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)); .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.addAssets(assets); timelineManager.upsertAssets(assets);
timelineManager.removeAssets(assets.map((asset) => asset.id)); timelineManager.removeAssets(assets.map((asset) => asset.id));
expect(timelineManager.assetCount).toEqual(0); expect(timelineManager.assetCount).toEqual(0);
@@ -429,7 +422,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
}), }),
); );
timelineManager.addAssets([assetOne, assetTwo]); timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getFirstAsset()).toEqual(assetOne); expect(timelineManager.getFirstAsset()).toEqual(assetOne);
}); });
}); });
@@ -477,45 +470,45 @@ describe('TimelineManager', () => {
}); });
it('returns previous assetId', async () => { it('returns previous assetId', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 }); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
const a = month!.getAssets()[0]; const a = month!.assets[0];
const b = month!.getAssets()[1]; const b = month!.assets[1];
const previous = await timelineManager.getLaterAsset(b); const previous = await timelineManager.getLaterAsset(b);
expect(previous).toEqual(a); expect(previous).toEqual(a);
}); });
it('returns previous assetId spanning multiple months', async () => { it('returns previous assetId spanning multiple months', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 2 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
await timelineManager.loadMonthGroup({ year: 2024, month: 3 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 }); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 }); const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getAssets()[0]; const a = month!.assets[0];
const b = previousMonth!.getAssets()[0]; const b = previousMonth!.assets[0];
const previous = await timelineManager.getLaterAsset(a); const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b); expect(previous).toEqual(b);
}); });
it('loads previous month', async () => { it('loads previous month', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 2 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 }); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 }); const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getFirstAsset(); const a = month!.getFirstAsset();
const b = previousMonth!.getFirstAsset(); const b = previousMonth!.getFirstAsset();
const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute'); const loadSegmentSpy = vi.spyOn(month!.loader!, 'execute');
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute'); const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a); const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b); expect(previous).toEqual(b);
expect(loadMonthGroupSpy).toBeCalledTimes(0); expect(loadSegmentSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0); expect(previousMonthSpy).toBeCalledTimes(0);
}); });
it('skips removed assets', async () => { it('skips removed assets', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
await timelineManager.loadMonthGroup({ year: 2024, month: 2 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
await timelineManager.loadMonthGroup({ year: 2024, month: 3 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager); const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
timelineManager.removeAssets([assetTwo.id]); timelineManager.removeAssets([assetTwo.id]);
@@ -523,7 +516,7 @@ describe('TimelineManager', () => {
}); });
it('returns null when no more assets', async () => { it('returns null when no more assets', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 3 }); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined(); expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
}); });
}); });
@@ -554,7 +547,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}), }),
); );
timelineManager.addAssets([assetOne, assetTwo]); timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024); expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2); expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
@@ -573,7 +566,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'), fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}), }),
); );
timelineManager.addAssets([assetOne, assetTwo]); timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]); timelineManager.removeAssets([assetTwo.id]);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024); expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);

View File

@@ -3,18 +3,19 @@ import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { clamp, debounce, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
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 { import {
addAssetsToMonthGroups, getSegmentIdentifier,
runAssetOperation, toTimelineAsset,
} from '$lib/managers/timeline-manager/internal/operations-support.svelte'; type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { isEqual } from 'lodash-es';
import { SvelteDate, 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 { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { import {
findMonthGroupForAsset as findMonthGroupForAssetUtil, findMonthGroupForAsset as findMonthGroupForAssetUtil,
findMonthGroupForDate, findMonthGroupForDate,
@@ -24,37 +25,15 @@ import {
} from '$lib/managers/timeline-manager/internal/search-support.svelte'; } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { DayGroup } from './day-group.svelte'; import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte'; import { isMismatched } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte'; import { MonthGroup } from './month-group.svelte';
import type { import type { AssetDescriptor, Direction, ScrubberMonth, TimelineAsset, TimelineManagerOptions } from './types';
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(); albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]); scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0); 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( initTask = new CancellableTask(
() => { () => {
@@ -72,121 +51,16 @@ export class TimelineManager {
); );
static #INIT_OPTIONS = {}; static #INIT_OPTIONS = {};
#viewportHeight = $state(0);
#viewportWidth = $state(0);
#scrollTop = $state(0);
#websocketSupport: WebsocketSupport | undefined; #websocketSupport: WebsocketSupport | undefined;
#rowHeight = $state(235);
#headerHeight = $state(48);
#gap = $state(12);
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS; #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
#scrolling = $state(false); get months() {
#suspendTransitions = $state(false); return this.#months;
#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();
}
} }
#setHeaderHeight(value: number) { get options() {
if (this.#headerHeight == value) { return this.#options;
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?: { async *assetsIterator(options?: {
@@ -198,7 +72,7 @@ export class TimelineManager {
const direction = options?.direction ?? 'earlier'; const direction = options?.direction ?? 'earlier';
let { startDayGroup, startAsset } = options ?? {}; let { startDayGroup, startAsset } = options ?? {};
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) { for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false }); await this.loadSegment(monthGroup.identifier, { cancelable: false });
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction }); yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
startDayGroup = startAsset = undefined; startDayGroup = startAsset = undefined;
} }
@@ -234,75 +108,24 @@ export class TimelineManager {
this.#websocketSupport = undefined; 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() { async #initializeMonthGroups() {
const timebuckets = await getTimeBuckets({ const timebuckets = await getTimeBuckets({
...authManager.params, ...authManager.params,
...this.#options, ...this.#options,
}); });
this.months = timebuckets.map((timeBucket) => { this.#months = timebuckets.map((timeBucket) => {
const date = new SvelteDate(timeBucket.timeBucket); const date = new SvelteDate(timeBucket.timeBucket);
return new MonthGroup( return new MonthGroup(
this, this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }, { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count, timeBucket.count,
false,
this.#options.order, this.#options.order,
); );
}); });
this.albumAssets.clear(); this.albumAssets.clear();
this.#updateViewportGeometry(false); this.updateViewportGeometry(false);
} }
async updateOptions(options: TimelineManagerOptions) { async updateOptions(options: TimelineManagerOptions) {
@@ -313,16 +136,16 @@ export class TimelineManager {
return; return;
} }
await this.initTask.reset(); await this.initTask.reset();
await this.#init(options); this.#options = options;
this.#updateViewportGeometry(false); await this.init();
this.updateViewportGeometry(false);
} }
async #init(options: TimelineManagerOptions) { async init() {
this.isInitialized = false; this.isInitialized = false;
this.months = []; this.#months = [];
this.albumAssets.clear(); this.albumAssets.clear();
await this.initTask.execute(async () => { await this.initTask.execute(async () => {
this.#options = options;
await this.#initializeMonthGroups(); await this.#initializeMonthGroups();
}, true); }, true);
} }
@@ -332,36 +155,8 @@ export class TimelineManager {
this.isInitialized = false; this.isInitialized = false;
} }
async updateViewport(viewport: Viewport) { updateViewportGeometry(changedWidth: boolean) {
if (viewport.height === 0 && viewport.width === 0) { super.updateViewportGeometry(changedWidth);
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(); this.#createScrubberMonths();
} }
@@ -376,45 +171,6 @@ export class TimelineManager {
this.scrubberTimelineHeight = this.timelineHeight; 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) { async findMonthGroupForAsset(id: string) {
if (!this.isInitialized) { if (!this.isInitialized) {
await this.initTask.waitUntilCompletion(); await this.initTask.waitUntilCompletion();
@@ -442,7 +198,7 @@ export class TimelineManager {
} }
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) { async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
await this.loadMonthGroup(yearMonth, options); await this.loadSegment(getSegmentIdentifier(yearMonth), options);
return getMonthGroupByDate(this, yearMonth); return getMonthGroupByDate(this, yearMonth);
} }
@@ -454,7 +210,7 @@ export class TimelineManager {
async getRandomMonthGroup() { async getRandomMonthGroup() {
const random = Math.floor(Math.random() * this.months.length); const random = Math.floor(Math.random() * this.months.length);
const month = this.months[random]; const month = this.months[random];
await this.loadMonthGroup(month.yearMonth, { cancelable: false }); await this.loadSegment(getSegmentIdentifier(month.yearMonth), { cancelable: false });
return month; return month;
} }
@@ -463,40 +219,6 @@ export class TimelineManager {
return month?.getRandomAsset(); 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() { refreshLayout() {
for (const month of this.months) { for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: true }); updateGeometry(this, month, { invalidateHeight: true });
@@ -527,7 +249,7 @@ export class TimelineManager {
if (!monthGroup) { if (!monthGroup) {
return; return;
} }
await this.loadMonthGroup(dateTime, { cancelable: false }); await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false });
const asset = monthGroup.findClosest(dateTime); const asset = monthGroup.findClosest(dateTime);
if (asset) { if (asset) {
return asset; return asset;
@@ -552,4 +274,42 @@ export class TimelineManager {
getAssetOrder() { getAssetOrder() {
return this.#options.order ?? AssetOrder.Desc; 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; longitude?: number | null;
}; };
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; export type AssetOperation = (asset: TimelineAsset) => { remove: boolean } | unknown;
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate }; export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils'; import { getAssetRatio } from '$lib/utils/asset-utils';
@@ -23,11 +24,11 @@ export type TimelineDateTime = TimelineDate & {
millisecond: number; millisecond: number;
}; };
export type ScrubberListener = ( export type ScrubberListener = (scrubberData: {
scrubberMonth: { year: number; month: number }, scrubberMonth: { year: number; month: number };
overallScrollPercent: number, overallScrollPercent: number;
scrubberMonthScrollPercent: number, scrubberMonthScrollPercent: number;
) => void | Promise<void>; }) => void | Promise<void>;
// used for AssetResponseDto.dateTimeOriginal, amongst others // used for AssetResponseDto.dateTimeOriginal, amongst others
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> => export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
@@ -151,6 +152,14 @@ export function formatGroupTitle(_date: DateTime): string {
return getDateLocaleString(date, { locale: get(locale) }); 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 => export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
@@ -234,3 +243,11 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
} }
return result; 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,7 +34,6 @@
import { AlbumPageViewMode, AppRoute } from '$lib/constants'; import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte'; import { activityManager } from '$lib/managers/activity-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-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 AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte'; import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
@@ -262,18 +261,15 @@
} }
}; };
const handleSetVisibility = (assetIds: string[]) => { const handleSetVisibility = () => {
timelineManager.removeAssets(assetIds);
assetInteraction.clearMultiselect(); assetInteraction.clearMultiselect();
}; };
const handleRemoveAssets = async (assetIds: string[]) => { const handleRemoveAssets = async () => {
timelineManager.removeAssets(assetIds);
await refreshAlbum(); await refreshAlbum();
}; };
const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => { const handleUndoRemoveAssets = async () => {
timelineManager.addAssets(assets);
await refreshAlbum(); await refreshAlbum();
}; };
@@ -452,7 +448,7 @@
{isSelectionMode} {isSelectionMode}
{singleSelect} {singleSelect}
{showArchiveIcon} {showArchiveIcon}
{onSelect} onAssetSelect={onSelect}
onEscape={handleEscape} onEscape={handleEscape}
> >
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS} {#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
@@ -572,14 +568,7 @@
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
{#if assetInteraction.isAllUserOwned} {#if assetInteraction.isAllUserOwned}
<FavoriteAction <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager}></FavoriteAction>
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction>
{/if} {/if}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
<DownloadAction menuItem filename="{album.albumName}.zip" /> <DownloadAction menuItem filename="{album.albumName}.zip" />
@@ -594,7 +583,7 @@
onClick={() => updateThumbnailUsingCurrentSelection()} onClick={() => updateThumbnailUsingCurrentSelection()}
/> />
{/if} {/if}
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} /> <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} /> <SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if} {/if}
@@ -606,7 +595,12 @@
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} /> <RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
{/if} {/if}
{#if assetInteraction.isAllUserOwned} {#if assetInteraction.isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} onUndoDelete={handleUndoRemoveAssets} /> <DeleteAssets
menuItem
onAssetDelete={handleRemoveAssets}
onUndoDelete={handleUndoRemoveAssets}
manager={timelineManager}
/>
{/if} {/if}
</ButtonContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>

View File

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

View File

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

View File

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

View File

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

View File

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