Compare commits

..

30 Commits

Author SHA1 Message Date
midzelis
666bbf2503 fix: padding for searchresults/photostream 2025-09-28 20:55:19 +00:00
midzelis
009e1def3e feat: search results keyboard actions 2025-09-28 20:55:19 +00:00
midzelis
cc20b282f4 feat: search results page (without keyboard actions)
fix: missing responsive calculation in UserPageLayout
2025-09-28 20:55:19 +00:00
midzelis
d044889cb0 feat: Extract common StreamWithViewer component
- Create new StreamWithViewer component to handle asset viewer lifecycle
  and navigation
  - Move beforeNavigate/afterNavigate hooks from Timeline to StreamWithViewer
  - Extract asset viewer Portal rendering and close handler to wrapper
  component
  - Move timeline segment loading logic for viewed assets to StreamWithViewer
  - Simplify Timeline component by removing ~76 lines of navigation/viewer
  code
  - Remove showSkeleton state management from Timeline (now handled by
  PhotostreamWithScrubber)

  This separation of concerns makes the Timeline component more focused on
  rendering while StreamWithViewer handles all viewer-related navigation and state
  management.The new component can be reused by other photostream-like components that
  need asset viewer functionality.
2025-09-28 20:39:31 +00:00
midzelis
c144b1ad17 feat: photostream can have scrollbar, style options, standardize small/large layout sizes
- Add configurable header height props for responsive layouts
  (smallHeaderHeight/largeHeaderHeight)
  - Add style customization props: styleMarginContentHorizontal,
  styleMarginTop, alwaysShowScrollbar
  - Replace hardcoded layout values with configurable props
  - Change root element from <section> to custom <photostream> element for
  better semantic structure
  - Move viewport width binding to inner timeline element for more accurate
  measurements
  - Simplify HMR handler by removing file-specific checks
  - Add segment loading check to prevent rendering unloaded segments
  - Add spacing margin between month groups using layout options
  - Change scrollbar-width from 'auto' to 'thin' for consistency
  - Remove unused UpdatePayload type import
2025-09-28 20:39:31 +00:00
midzelis
c424b2f0a1 feat: skeleton title is optional
feat: skeleton title optional
2025-09-28 20:39:05 +00:00
midzelis
0a18d9c35e fix: back/forward navigation won't reset scroll in timeline 2025-09-28 19:41:41 +00:00
midzelis
25dbb60574 refactor(web): extract timeline scrolling logic into common Photostream components
- Extract common timeline functionality into Photostream.svelte
  base component
- Create PhotostreamWithScrubber.svelte to handle scrubber
  integration
- Simplify Timeline.svelte by removing ~300 lines of
  scrolling/scrubber logic
- Add findMonthAtScrollPosition utility with binary search for
  better performance
- Maintain all existing functionality while improving code
  organization
2025-09-28 19:41:41 +00:00
midzelis
a1e788e0bf 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-28 19:41:41 +00:00
midzelis
3c39f44fa0 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-28 19:41:41 +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
88 changed files with 3467 additions and 2689 deletions

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: Check what should run
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:
filters: |
mobile:
@@ -73,7 +73,7 @@ jobs:
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.gradle/caches
@@ -130,7 +130,7 @@ jobs:
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
if: github.ref == 'refs/heads/main'
with:
path: |

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -116,7 +116,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (Platform.isAndroid) {
await _backgroundHostApi.showNotification(
IntlKeys.uploading_media.t(),
IntlKeys.backup_background_service_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 {
LocalAsset toDto() => LocalAsset(
LocalAsset toDto({String? remoteId}) => LocalAsset(
id: id,
name: name,
checksum: checksum,
@@ -32,7 +32,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
isFavorite: isFavorite,
height: height,
width: width,
remoteId: null,
remoteId: remoteId,
orientation: orientation,
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final actions = <Widget>[
if (asset.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 (album != null && album.isActivityEnabled && album.isShared)
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/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -44,6 +45,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View File

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

View File

@@ -5,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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -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/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -62,11 +64,19 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return;
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
.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(
context: context,
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 MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
slivers: multiselect.hasRemote
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: [],
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
@@ -10,13 +11,14 @@ class MapBottomSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const BaseBottomSheet(
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.9,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
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/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -102,6 +103,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@@ -123,28 +123,14 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
return provider;
}
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = kThumbnailResolution}) {
assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
if (remoteId != null) {
return RemoteThumbProvider(assetId: remoteId);
}
if (_shouldUseLocalAsset(asset!)) {
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
}
final String assetId;
if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!;
} else if (asset is RemoteAsset) {
assetId = asset.id;
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
return RemoteThumbProvider(assetId: assetId);
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null;
}
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/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -39,14 +38,7 @@ class Thumbnail extends StatefulWidget {
),
_ => null,
},
imageProvider = switch (asset) {
RemoteAsset() =>
asset.localId == null
? RemoteThumbProvider(assetId: asset.id)
: LocalThumbProvider(id: asset.localId!, size: size, assetType: asset.type),
LocalAsset() => LocalThumbProvider(id: asset.id, size: size, assetType: asset.type),
_ => null,
};
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size);
@override
State<Thumbnail> createState() => _ThumbnailState();

View File

@@ -54,8 +54,6 @@ class ThumbnailTile extends ConsumerWidget {
)
: const BoxDecoration();
final hasStack = asset is RemoteAsset && asset.stackId != null;
final bool storageIndicator =
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
@@ -77,21 +75,10 @@ class ThumbnailTile extends ConsumerWidget {
child: Thumbnail.fromAsset(asset: asset, size: size),
),
),
if (hasStack)
if (asset != null)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, top: asset.isVideo ? 24.0 : 6.0),
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
),
),
if (asset != null && asset.isVideo)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.only(right: 10.0, top: 6.0),
child: _VideoIndicator(asset.duration),
),
child: _AssetTypeIcons(asset: asset),
),
if (storageIndicator && asset != null)
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:io';
import 'dart:math';
import 'package:flutter/material.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/translate_extensions.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_utils.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -187,6 +188,8 @@ class _Map extends StatelessWidget {
styleString: style,
onMapCreated: onMapCreated,
onStyleLoadedCallback: onMapReady,
attributionButtonPosition: AttributionButtonPosition.topRight,
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
),
),
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,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/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
class ActionButtonContext {
@@ -24,6 +25,7 @@ class ActionButtonContext {
final bool isArchived;
final bool isTrashEnabled;
final bool isInLockedView;
final bool isStacked;
final RemoteAlbum? currentAlbum;
final bool advancedTroubleshooting;
final ActionSource source;
@@ -33,6 +35,7 @@ class ActionButtonContext {
required this.isOwner,
required this.isArchived,
required this.isTrashEnabled,
required this.isStacked,
required this.isInLockedView,
required this.currentAlbum,
required this.advancedTroubleshooting,
@@ -55,6 +58,7 @@ enum ActionButtonType {
deleteLocal,
upload,
removeFromAlbum,
unstack,
likeActivity;
bool shouldShow(ActionButtonContext context) {
@@ -110,6 +114,10 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.currentAlbum != null,
ActionButtonType.unstack =>
context.isOwner && //
!context.isInLockedView && //
context.isStacked,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
@@ -138,28 +146,13 @@ enum ActionButtonType {
source: context.source,
),
ActionButtonType.likeActivity => const LikeActivityActionButton(),
ActionButtonType.unstack => UnStackActionButton(source: context.source),
};
}
}
class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = [
ActionButtonType.advancedInfo,
ActionButtonType.share,
ActionButtonType.shareLink,
ActionButtonType.likeActivity,
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.download,
ActionButtonType.trash,
ActionButtonType.deletePermanent,
ActionButtonType.delete,
ActionButtonType.moveToLockFolder,
ActionButtonType.removeFromLockFolder,
ActionButtonType.deleteLocal,
ActionButtonType.upload,
ActionButtonType.removeFromAlbum,
];
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
static List<Widget> build(ActionButtonContext context) {
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(
builder: (BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(
builder: (context) {
return Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset(
context.isDarkTheme
? 'assets/immich-logo-inline-dark.svg'
: 'assets/immich-logo-inline-light.svg',
height: 40,
),
);
},
Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset(
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
height: 40,
),
),
const Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: Duration(seconds: 4),
message:
"The old timeline is deprecated and will be removed in a future release. Kindly switch to the new timeline under Advanced Settings.",
child: Padding(
padding: EdgeInsets.only(top: 3.0),
child: Icon(Icons.error_rounded, fill: 1, color: Colors.amber, size: 20),
),
),
],
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,11 @@
import type { ActionReturn } from 'svelte/action';
interface ExplainedShortcut {
key: string[];
action: string;
info?: string;
}
export type Shortcut = {
key: string;
alt?: boolean;
@@ -14,6 +20,7 @@ export type ShortcutOptions<T = HTMLElement> = {
ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
preventDefault?: boolean;
explainedShortcut?: ExplainedShortcut;
};
export const shortcutLabel = (shortcut: Shortcut) => {

View File

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

View File

@@ -49,7 +49,7 @@
<div
tabindex="-1"
class="relative z-0 grid grid-cols-[--spacing(0)_auto] overflow-hidden sidebar:grid-cols-[--spacing(64)_auto]
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))]'}
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))] max-md:h-[calc(100dvh-var(--navbar-height-md))]'}
{hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
>

View File

@@ -0,0 +1,237 @@
<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 { AppRoute } from '$lib/constants';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { moveFocus } from '$lib/utils/focus-util';
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { t } from 'svelte-i18n';
let { isViewing: isViewerOpen } = assetViewingStore;
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
interface Props {
timelineManager: PhotostreamManager;
assetInteraction: AssetInteraction;
isShowDeleteConfirmation?: boolean;
onReload?: (() => void) | undefined;
}
let {
timelineManager,
assetInteraction,
isShowDeleteConfirmation = false,
onReload,
}: Props = $props();
const selectAllAssets = () => {
const allAssets = timelineManager.months.flatMap((segment) => segment.assets);
assetInteraction.selectAssets(allAssets);
};
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
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 trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
onReload,
);
assetInteraction.clearMultiselect();
};
const toggleArchive = async () => {
const assets = assetInteraction.selectedAssets;
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assets, visibility);
const idSet = new Set(ids);
if (ids) {
for (const asset of assets) {
if (idSet.has(asset.id)) {
asset.visibility = visibility;
}
}
deselectAllAssets();
}
};
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, { shortcuts: getShortcuts() });
isShortcutModalOpen = false;
};
const getShortcuts = () => {
const general = Object.values(generalShortcuts);
const actions = Object.values(actionsShortcuts);
return {
general: general
.filter((general) => 'explainedShortcut' in general)
.map((generalShortcut) => generalShortcut.explainedShortcut!),
actions: actions.filter((action) => 'explainedShortcut' in action).map((action) => action.explainedShortcut!),
};
};
const generalShortcuts = {
OPEN_HELP: {
shortcut: { key: '?', shift: true },
onShortcut: handleOpenShortcutModal,
explainedShortcut: {
key: ['⇧', '?'],
action: 'Open this dialog',
},
},
EXPLORE: {
shortcut: { key: '/' },
onShortcut: () => goto(AppRoute.EXPLORE),
explainedShortcut: {
key: ['/'],
action: $t('explore'),
},
},
SELECT_ALL: {
shortcut: { key: 'A', ctrl: true },
onShortcut: () => selectAllAssets(),
explainedShortcut: {
key: ['Ctrl', 'a'],
action: $t('select_all'),
},
},
ARROW_RIGHT: {
shortcut: { key: 'ArrowRight' },
preventDefault: false,
onShortcut: focusNextAsset,
explainedShortcut: {
key: ['←', '→'],
action: $t('previous_or_next_photo'),
},
},
ARROW_LEFT: {
shortcut: { key: 'ArrowLeft' },
preventDefault: false,
onShortcut: focusPreviousAsset,
},
};
const actionsShortcuts = {
ESCAPE: {
shortcut: { key: 'Escape' },
onShortcut: deselectAllAssets,
explainedShortcut: {
key: ['Esc'],
action: $t('back_close_deselect'),
},
},
DELETE: {
shortcut: { key: 'Delete' },
onShortcut: onDelete,
explainedShortcut: {
key: ['Del'],
action: $t('trash_delete_asset'),
info: $t('shift_to_permanent_delete'),
},
},
FORCE_DELETE: {
shortcut: { key: 'Delete', shift: true },
onShortcut: onForceDelete,
},
DESELECT_ALL: {
shortcut: { key: 'D', ctrl: true },
onShortcut: () => deselectAllAssets(),
explainedShortcut: {
key: ['Ctrl', 'd'],
action: $t('deselect_all'),
},
},
TOGGLE_ARCHIVE: {
shortcut: { key: 'a', shift: true },
onShortcut: toggleArchive,
explainedShortcut: {
key: ['⇧', 'a'],
action: $t('select_all'),
},
},
};
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
return [];
}
const shortcuts: ShortcutOptions[] = [
generalShortcuts.OPEN_HELP,
generalShortcuts.EXPLORE,
generalShortcuts.SELECT_ALL,
generalShortcuts.ARROW_RIGHT,
generalShortcuts.ARROW_LEFT,
];
if (assetInteraction.selectionActive) {
shortcuts.push(
actionsShortcuts.ESCAPE,
actionsShortcuts.DELETE,
actionsShortcuts.FORCE_DELETE,
actionsShortcuts.DESELECT_ALL,
actionsShortcuts.TOGGLE_ARCHIVE,
);
}
return shortcuts;
})(),
);
</script>
<svelte:document use:shortcuts={shortcutList} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={assetInteraction.selectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import SearchKeyboardShortcuts from '$lib/components/search/SearchKeyboardShortcuts.svelte';
import SearchResultsAssetViewer from '$lib/components/search/SearchResultsAssetViewer.svelte';
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import Photostream from '$lib/components/timeline/Photostream.svelte';
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
import StreamWithViewer from '$lib/components/timeline/StreamWithViewer.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Snippet } from 'svelte';
interface Props {
assetInteraction: AssetInteraction;
children?: Snippet<[]>;
stylePaddingHorizontalPx?: number;
styleMarginTopPx?: number;
searchResultsManager: SearchResultsManager;
}
let {
searchResultsManager,
assetInteraction,
children,
stylePaddingHorizontalPx = 0,
styleMarginTopPx,
}: Props = $props();
let viewer: Photostream | undefined = $state(undefined);
const onAfterNavigateComplete = ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) =>
viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
</script>
<StreamWithViewer timelineManager={searchResultsManager} {onAfterNavigateComplete}>
{#snippet assetViewer({ onViewerClose })}
<SearchResultsAssetViewer timelineManager={searchResultsManager} {onViewerClose} />
{/snippet}
<SearchKeyboardShortcuts {assetInteraction} timelineManager={searchResultsManager} />
<Photostream
bind:this={viewer}
{stylePaddingHorizontalPx}
{styleMarginTopPx}
showScrollbar={true}
alwaysShowScrollbar={true}
enableRouting={true}
timelineManager={searchResultsManager}
isShowDeleteConfirmation={true}
smallHeaderHeight={{ rowHeight: 100, headerHeight: 2 }}
largeHeaderHeight={{ rowHeight: 235, headerHeight: 2 }}
>
{@render children?.()}
{#snippet skeleton({ segment })}
<Skeleton height={segment.height - segment.timelineManager.headerHeight} {stylePaddingHorizontalPx} />
{/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
<SelectableSegment
{segment}
{onScrollCompensationMonthInDOM}
timelineManager={searchResultsManager}
{assetInteraction}
isSelectionMode={false}
singleSelect={false}
>
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
<AssetLayout
photostreamManager={searchResultsManager}
viewerAssets={segment.viewerAssets}
height={segment.height}
width={searchResultsManager.viewportWidth}
>
{#snippet thumbnail({ asset, position })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected = assetInteraction.hasSelectedAsset(asset.id)}
<Thumbnail
showStackedIcon={true}
showArchiveIcon={true}
{asset}
onClick={() => onAssetOpen(asset)}
onSelect={() => onAssetSelect(asset)}
onMouseEvent={() => onAssetHover(asset)}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</AssetLayout>
{/snippet}
</SelectableSegment>
{/snippet}
</Photostream>
</StreamWithViewer>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants';
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { navigate } from '$lib/utils/navigation';
let { asset: viewingAsset, setAssetId, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: SearchResultsManager;
onViewerClose?: (asset: { id: string }) => Promise<void>;
}
let { timelineManager, onViewerClose = () => Promise.resolve(void 0) }: Props = $props();
const handleNext = async (): Promise<boolean> => {
const next = timelineManager.findNextAsset($viewingAsset.id);
if (next) {
await navigateToAsset(next);
return true;
}
return false;
};
const handleRandom = async (): Promise<{ id: string } | undefined> => {
const next = timelineManager.findRandomAsset();
if (next) {
await navigateToAsset(next);
return { id: next.id };
}
};
const handlePrevious = async (): Promise<boolean> => {
const next = timelineManager.findPreviousAsset($viewingAsset.id);
if (next) {
await navigateToAsset(next);
return true;
}
return false;
};
const navigateToAsset = async (asset?: { id: string }) => {
if (asset && asset.id !== $viewingAsset.id) {
await setAssetId(asset.id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
timelineManager.removeAssets([action.asset.id]);
if (!(await handlePrevious())) {
await goto(AppRoute.PHOTOS);
}
break;
}
}
};
</script>
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
preloadAssets={$preloadAssets}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={onViewerClose}
/>
{/await}

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

@@ -0,0 +1,307 @@
<script lang="ts">
import { page } from '$app/state';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { onMount, type Snippet } from 'svelte';
interface Props {
segment: Snippet<
[
{
segment: PhotostreamSegment;
scrollToFunction: (top: number) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
},
]
>;
skeleton: Snippet<
[
{
segment: PhotostreamSegment;
stylePaddingHorizontalPx: number;
},
]
>;
showScrollbar?: boolean;
/** `true` if this asset grid responds to navigation events; if `true`, then look at the
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
additionally, update the page location/url with the asset as the asset-grid is scrolled */
enableRouting: boolean;
timelineManager: PhotostreamManager;
alwaysShowScrollbar?: boolean;
showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean;
header?: Snippet<[scrollToFunction: (top: number) => void]>;
children?: Snippet;
empty?: Snippet;
handleTimelineScroll?: () => void;
smallHeaderHeight?: {
rowHeight: number;
headerHeight: number;
};
largeHeaderHeight?: {
rowHeight: number;
headerHeight: number;
};
stylePaddingHorizontalPx?: number;
styleMarginTopPx?: number;
styleMarginRightPx?: number;
}
let {
segment,
enableRouting,
timelineManager = $bindable(),
showSkeleton = $bindable(true),
showScrollbar,
styleMarginRightPx = 0,
stylePaddingHorizontalPx = 0,
styleMarginTopPx = 0,
alwaysShowScrollbar,
isShowDeleteConfirmation = $bindable(false),
children,
skeleton,
empty,
header,
handleTimelineScroll = () => {},
smallHeaderHeight = {
rowHeight: 100,
headerHeight: 32,
},
largeHeaderHeight = {
rowHeight: 235,
headerHeight: 48,
},
}: Props = $props();
let { gridScrollTarget } = assetViewingStore;
let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
const maxMd = $derived(mobileDevice.maxMd);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
$effect(() => {
const layoutOptions = maxMd ? smallHeaderHeight : largeHeaderHeight;
timelineManager.setLayoutOptions(layoutOptions);
});
const scrollTo = (top: number) => {
if (element) {
element.scrollTo({ top });
}
updateSlidingWindow();
};
const scrollBy = (y: number) => {
if (element) {
element.scrollBy(0, y);
}
updateSlidingWindow();
};
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: PhotostreamSegment) => {
// the following method may trigger any layouts, so need to
// handle any scroll compensation that may have been set
const height = monthGroup.findAssetAbsolutePosition(assetId);
// this is in a while loop, since scrollCompensations invoke scrolls
// which may load months, triggering more scrollCompensations. Call
// this in a loop, until no more layouts occur.
while (timelineManager.scrollCompensation.monthGroup) {
handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
}
return height;
};
export const scrollToAssetId = async (assetId: string) => {
const monthGroup = await timelineManager.findSegmentForAssetId(assetId);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(assetId, monthGroup);
scrollTo(height);
return true;
};
export const completeAfterNavigate = async ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) => {
if (timelineManager.viewportHeight === 0 || timelineManager.viewportWidth === 0) {
// this can happen if you do the following navigation order
// /photos?at=<id>, /photos/<id>, http://example.com, browser back, browser back
const rect = element?.getBoundingClientRect();
if (rect) {
timelineManager.viewportHeight = rect.height;
timelineManager.viewportWidth = rect.width;
}
}
if (scrollToAssetQueryParam) {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollToAssetId(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollTo(0);
}
}
showSkeleton = false;
};
const updateIsScrolling = () => (timelineManager.scrolling = true);
// Yes, updateSlideWindow() is called by the onScroll event. However, if you also just scrolled
// by explicitly invoking element.scrollX functions, there may be a delay with 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.
// Also note: don't throttle, debounce, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => {
if (!enableRouting) {
showSkeleton = false;
}
});
</script>
<HotModuleReload
onAfterUpdate={() => {
// when hmr happens, skeleton is initialized to true by default
// normally, loading asset-grid is part of a navigation event, and the completion of
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
// preventing skeleton from showing after hmr
const finishHmr = () => {
const asset = page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
}
void completeAfterNavigate({ scrollToAssetQueryParam: true });
};
// wait 500ms for the update to be fully swapped in
setTimeout(finishHmr, 500);
}}
/>
{@render header?.(scrollTo)}
<!-- Right margin MUST be equal to the width of scrubber -->
<photostream
id="asset-grid"
class={[
'overflow-y-auto outline-none',
{ 'scrollbar-hidden': !showScrollbar },
{ 'overflow-y-scroll': alwaysShowScrollbar },
{ 'm-0': isEmpty },
{ 'ms-0': !isEmpty },
]}
style:height={`calc(100% - ${styleMarginTopPx}px)`}
style:margin-top={styleMarginTopPx + 'px'}
style:margin-right={styleMarginRightPx + 'px'}
style:padding-left={stylePaddingHorizontalPx + 'px'}
style:padding-right={stylePaddingHorizontalPx + 'px'}
style:scrollbar-width={showScrollbar ? 'thin' : 'none'}
tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={
null, (v: number) => ((timelineManager.viewportWidth = v - stylePaddingHorizontalPx * 2), updateSlidingWindow())
}
bind:this={element}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:relative={true}
class:invisible={showSkeleton}
style:height={timelineManager.timelineHeight + 'px'}
>
<section
use:resizeObserver={topSectionResizeObserver}
class:invisible={showSkeleton}
style:position="absolute"
style:left="0"
style:right="0"
>
{@render children?.()}
{#if isEmpty}
<!-- (optional) empty placeholder -->
{@render empty?.()}
{/if}
</section>
{#each timelineManager.months as monthGroup (monthGroup.id)}
{@const shouldDisplay = monthGroup.intersecting && monthGroup.isLoaded}
{@const absoluteHeight = monthGroup.top}
<div
class="month-group"
style:margin-bottom={timelineManager.createLayoutOptions().spacing + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:height={`${monthGroup.height}px`}
style:width="100%"
>
{#if !shouldDisplay}
{@render skeleton({ segment: monthGroup, stylePaddingHorizontalPx })}
{:else}
{@render segment({
segment: monthGroup,
scrollToFunction: scrollTo,
onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation,
})}
{/if}
</div>
{/each}
<!-- spacer for lead-out -->
<div
class="h-[60px]"
style:position="absolute"
style:left="0"
style:right="0"
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
></div>
</section>
</photostream>
<style>
photostream {
display: block;
}
#asset-grid {
contain: strict;
scrollbar-width: none;
}
.month-group {
contain: layout size paint;
transform-style: flat;
backface-visibility: hidden;
transform-origin: center center;
}
</style>

View File

@@ -0,0 +1,198 @@
<script lang="ts">
import Photostream from '$lib/components/timeline/Photostream.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { findMonthAtScrollPosition } from '$lib/managers/timeline-manager/internal/search-support.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 { type ScrubberListenerWithScrollTo, type TimelineYearMonth } from '$lib/utils/timeline-util';
import type { Snippet } from 'svelte';
interface Props {
/** `true` if this timeline responds to navigation events; if `true`, then look at the
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
additionally, update the page location/url with the asset as the asset-grid is scrolled */
enableRouting: boolean;
timelineManager: TimelineManager;
showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean;
segment: Snippet<
[
{
segment: PhotostreamSegment;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
},
]
>;
skeleton: Snippet<
[
{
segment: PhotostreamSegment;
},
]
>;
children?: Snippet;
empty?: Snippet;
}
let {
enableRouting,
timelineManager = $bindable(),
showSkeleton = true,
isShowDeleteConfirmation = $bindable(false),
segment,
skeleton,
children,
empty,
}: Props = $props();
const VIEWPORT_MULTIPLIER = 2; // Used to determine if timeline is "small"
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
// Note: There may be multiple months visible within the viewport at any given time.
let viewportTopMonthScrollPercent = $state(0);
// The timeline month intersecting the top position of the viewport
let viewportTopMonth: TimelineYearMonth | undefined = $state(undefined);
// Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0);
// Indicates whether the viewport is currently in the lead-out section (after all months)
let isInLeadOutSection = $state(false);
// Width of the scrubber component in pixels, used to adjust timeline margins
let scrubberWidth: number = $state(0);
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function updates the scrubber position based on the current scroll position in the timeline
const handleTimelineScroll = () => {
isInLeadOutSection = false;
// Handle edge cases: small timeline (limited scroll) or lead-in area scrolling
const top = timelineManager.visibleWindow.top;
if (isSmallTimeline() || top < timelineManager.topSectionHeight) {
calculateTimelineScrollPercent();
return;
}
// Handle normal month scrolling
handleMonthScroll();
};
const handleMonthScroll = () => {
const scrollPosition = timelineManager.visibleWindow.top;
const months = timelineManager.months;
const maxScrollPercent = timelineManager.getMaxScrollPercent();
// Find the month at the current scroll position
const searchResult = findMonthAtScrollPosition(months, scrollPosition, maxScrollPercent);
if (searchResult) {
viewportTopMonth = searchResult.month;
viewportTopMonthScrollPercent = searchResult.monthScrollPercent;
isInLeadOutSection = false;
return;
}
// We're in lead-out section
isInLeadOutSection = true;
timelineScrollPercent = 1;
resetScrubberMonth();
};
const resetScrubberMonth = () => {
viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0;
};
const calculateTimelineScrollPercent = () => {
const maxScroll = timelineManager.getMaxScroll();
timelineScrollPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll);
resetScrubberMonth();
};
const handleOverallPercentScroll = (percent: number, scrollTo?: (offset: number) => void) => {
const maxScroll = timelineManager.getMaxScroll();
const offset = maxScroll * percent;
scrollTo?.(offset);
};
const findMonthGroup = (target: TimelineYearMonth) => {
return timelineManager.months.find(
({ yearMonth }) => yearMonth.year === target.year && yearMonth.month === target.month,
);
};
const isSmallTimeline = () => {
return timelineManager.timelineHeight < timelineManager.viewportHeight * VIEWPORT_MULTIPLIER;
};
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
const onScrub: ScrubberListenerWithScrollTo = (scrubberData) => {
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent, scrollToFunction } = scrubberData;
// Handle edge case or no month selected
if (!scrubberMonth || isSmallTimeline()) {
handleOverallPercentScroll(overallScrollPercent, scrollToFunction);
return;
}
// Find and scroll to the selected month
const monthGroup = findMonthGroup(scrubberMonth);
if (monthGroup) {
scrollToPositionWithinMonth(monthGroup, scrubberMonthScrollPercent, scrollToFunction);
}
};
const scrollToPositionWithinMonth = (
monthGroup: MonthGroup,
monthGroupScrollPercent: number,
handleScrollTop?: (top: number) => void,
) => {
const topOffset = monthGroup.top;
const maxScrollPercent = timelineManager.getMaxScrollPercent();
const delta = monthGroup.height * monthGroupScrollPercent;
const scrollToTop = (topOffset + delta) * maxScrollPercent;
handleScrollTop?.(scrollToTop);
};
let baseTimelineViewer: Photostream | undefined = $state();
export const scrollToAsset = async (asset: TimelineAsset) =>
(await baseTimelineViewer?.scrollToAssetId(asset.id)) ?? false;
export const completeAfterNavigate = (args: { scrollToAssetQueryParam: boolean }) =>
baseTimelineViewer?.completeAfterNavigate(args);
</script>
<Photostream
bind:this={baseTimelineViewer}
{enableRouting}
{timelineManager}
{showSkeleton}
{isShowDeleteConfirmation}
styleMarginRightPx={scrubberWidth}
{handleTimelineScroll}
{segment}
{skeleton}
{children}
{empty}
>
{#snippet header(scrollToFunction)}
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
{isInLeadOutSection}
{timelineScrollPercent}
{viewportTopMonthScrollPercent}
{viewportTopMonth}
onScrub={(scrubberData) => onScrub({ ...scrubberData, scrollToFunction })}
bind:scrubberWidth
/>
{/if}
{/snippet}
</Photostream>

View File

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

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

@@ -0,0 +1,76 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import Portal from '$lib/elements/Portal.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
import { DateTime } from 'luxon';
import { type Snippet } from 'svelte';
interface Props {
timelineManager: PhotostreamManager;
children?: Snippet;
assetViewer: Snippet<[{ onViewerClose: (asset: { id: string }) => Promise<void> }]>;
onAfterNavigateComplete: (args: { scrollToAssetQueryParam: boolean }) => void;
}
let { timelineManager, children, assetViewer, onAfterNavigateComplete }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
// tri-state boolean
let initialLoadWasAssetViewer: boolean | null = null;
let hasNavigatedToOrFromAssetViewer: boolean = false;
let timelineScrollPositionInitialized = false;
beforeNavigate(({ from, to }) => {
timelineManager.suspendTransitions = true;
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
});
const completeAfterNavigate = () => {
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
let isInitial = false;
// Set initial load state only once
if (initialLoadWasAssetViewer === null) {
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
isInitial = true;
}
let scrollToAssetQueryParam = false;
if (
!timelineScrollPositionInitialized &&
((isInitial && !assetViewerPage) || // Direct timeline load
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
) {
scrollToAssetQueryParam = true;
timelineScrollPositionInitialized = true;
}
return onAfterNavigateComplete({ scrollToAssetQueryParam });
};
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
const onViewerClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
}
});
</script>
{@render children?.()}
<Portal target="body">
{#if $showAssetViewer}
{@render assetViewer({ onViewerClose })}
{/if}
</Portal>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
<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, mutex, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
onViewerClose?: (asset: { id: string }) => Promise<void>;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE
| null;
}
let {
timelineManager,
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
onViewerClose = () => Promise.resolve(void 0),
}: 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 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 onViewerClose?.(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;
}
}
};
</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={onViewerClose}
/>
{/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

@@ -0,0 +1,225 @@
<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 setFocusAssetUtil,
setFocusTo as setFocusToUtil,
type FocusDirection,
type FocusInterval,
} 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) => Promise<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.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();
};
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 = (direction: FocusDirection, interval: FocusInterval) =>
setFocusToUtil(scrollToAsset, timelineManager, direction, interval);
const setFocusAsset = (asset: TimelineAsset) => setFocusAssetUtil(scrollToAsset, asset);
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) {
void setFocusAsset(asset);
}
}
}}
onCancel={() => (isShowSelectDate = false)}
/>
{/if}

View File

@@ -21,19 +21,26 @@ export const focusPreviousAsset = () =>
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
const scrolled = scrollToAsset(asset);
export const setFocusToAsset = async (
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
asset: TimelineAsset,
) => {
const scrolled = await scrollToAsset(asset);
if (scrolled) {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
}
};
export type FocusDirection = 'earlier' | 'later';
export type FocusInterval = 'day' | 'month' | 'year' | 'asset';
export const setFocusTo = async (
scrollToAsset: (asset: TimelineAsset) => boolean,
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
store: TimelineManager,
direction: 'earlier' | 'later',
interval: 'day' | 'month' | 'year' | 'asset',
direction: FocusDirection,
interval: FocusInterval,
) => {
if (tracker.isActive()) {
// there are unfinished running invocations, so return early
@@ -65,7 +72,10 @@ export const setFocusTo = async (
return;
}
const scrolled = scrollToAsset(asset);
const scrolled = await scrollToAsset(asset);
if (!invocation.isStillValid()) {
return;
}
if (scrolled) {
await tick();
if (!invocation.isStillValid()) {

View File

@@ -1,24 +1,29 @@
<script lang="ts">
interface Props {
height: number;
title: string;
title?: string;
stylePaddingHorizontalPx?: number;
}
let { height = 0, title }: Props = $props();
let { height = 0, title, stylePaddingHorizontalPx = 0 }: Props = $props();
</script>
<div class="overflow-clip" style:height={height + 'px'}>
<skeleton class="overflow-clip" style:height={height + 'px'}>
{#if title}
<div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
>
{title}
</div>
{/if}
<div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
>
{title}
</div>
<div
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
style:width="calc(100% - 20px)"
class="animate-pulse absolute h-full"
style:width="100%"
style:padding-left={stylePaddingHorizontalPx + 'px'}
style:padding-right={stylePaddingHorizontalPx + 'px'}
data-skeleton="true"
></div>
</div>
</skeleton>
<style>
[data-skeleton] {

View File

@@ -0,0 +1,354 @@
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 type {
PhotostreamSegment,
SegmentIdentifier,
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import type {
AssetDescriptor,
TimelineAsset,
TimelineManagerLayoutOptions,
Viewport,
} from '$lib/managers/timeline-manager/types';
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));
}
findSegmentForAssetId(assetId: string): Promise<PhotostreamSegment | undefined> {
for (const month of this.months) {
const asset = month.assets.find((asset) => asset.id === assetId);
if (asset) {
return Promise.resolve(month);
}
}
return Promise.resolve(void 0);
}
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));
}
removeAssets(assetIds: string[]): string[] {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const assetIdSet = new Set(assetIds);
const removedIds: string[] = [];
for (const segment of this.months) {
// Remove assets from each segment using splice
for (let i = segment.viewerAssets.length - 1; i >= 0; i--) {
if (assetIdSet.has(segment.viewerAssets[i].asset.id)) {
removedIds.push(segment.viewerAssets[i].asset.id);
segment.viewerAssets.splice(i, 1);
}
}
// Update segment layout after removing assets
if (removedIds.length > 0) {
segment.layout();
}
}
// Update viewport geometry after removals
if (removedIds.length > 0) {
this.updateViewportGeometry(false);
}
// Return the IDs that were not found/removed
return assetIds.filter((id) => !removedIds.includes(id));
}
}

View File

@@ -0,0 +1,155 @@
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 { 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>;
async reload(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
await this.loader.reset();
return await this.load(cancelable);
}
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;
}

View File

@@ -0,0 +1,110 @@
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { SearchResultsSegment } from '$lib/managers/searchresults-manager/SearchResultsSegment.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
export type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'> & { isVisible: boolean };
export type Options = {
searchTerms: SearchTerms;
isSmartSearchEnabled: boolean;
};
export class SearchResultsManager extends PhotostreamManager {
#options: Options = {
searchTerms: {
isVisible: false,
},
isSmartSearchEnabled: false,
};
#months: SearchResultsSegment[] = $state([]);
get options() {
return this.#options;
}
async updateOptions(options: Options) {
if (isEqual(this.#options, options)) {
return;
}
await this.initTask.reset();
this.#options = options;
await this.init();
this.updateViewportGeometry(false);
}
async init() {
this.isInitialized = false;
await this.initTask.execute(() => {
this.#months.splice(0);
this.#months.push(new SearchResultsSegment(this, '1', { ...this.#options.searchTerms }));
return Promise.resolve();
}, true);
this.updateViewportGeometry(false);
}
get months(): SearchResultsSegment[] {
return this.#months;
}
findNextAsset(currentAssetId: string): { id: string } | undefined {
for (let segmentIndex = 0; segmentIndex < this.#months.length; segmentIndex++) {
const segment = this.#months[segmentIndex];
const assetIndex = segment.assets.findIndex((asset) => asset.id === currentAssetId);
if (assetIndex !== -1) {
// Found the current asset
if (assetIndex < segment.assets.length - 1) {
// Next asset is in the same segment
return segment.assets[assetIndex + 1];
} else if (segmentIndex < this.#months.length - 1) {
// Next asset is in the next segment
const nextSegment = this.#months[segmentIndex + 1];
if (nextSegment.assets.length > 0) {
return nextSegment.assets[0];
}
}
break;
}
}
return undefined;
}
findPreviousAsset(currentAssetId: string): { id: string } | undefined {
for (let segmentIndex = 0; segmentIndex < this.#months.length; segmentIndex++) {
const segment = this.#months[segmentIndex];
const assetIndex = segment.assets.findIndex((asset) => asset.id === currentAssetId);
if (assetIndex !== -1) {
// Found the current asset
if (assetIndex > 0) {
// Previous asset is in the same segment
return segment.assets[assetIndex - 1];
} else if (segmentIndex > 0) {
// Previous asset is in the previous segment
const previousSegment = this.#months[segmentIndex - 1];
if (previousSegment.assets.length > 0) {
return previousSegment.assets.at(-1);
}
}
break;
}
}
return undefined;
}
findRandomAsset(): { id: string } | undefined {
// Get all loaded assets across all segments
const allAssets = this.#months.flatMap((segment) => segment.assets);
if (allAssets.length === 0) {
return undefined;
}
// Return a random asset
const randomIndex = Math.floor(Math.random() * allAssets.length);
return allAssets[randomIndex];
}
}

View File

@@ -0,0 +1,122 @@
import {
PhotostreamSegment,
type SegmentIdentifier,
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import type {
SearchResultsManager,
SearchTerms,
} from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { searchAssets, searchSmart } from '@immich/sdk';
import { isEqual } from 'lodash-es';
const {
TIMELINE: { INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export class SearchResultsSegment extends PhotostreamSegment {
#manager: SearchResultsManager;
#identifier: SegmentIdentifier;
#id: string;
#searchTerms: SearchTerms;
#currentPage: string | null = null;
#nextPage: string | null = null;
#viewerAssets: ViewerAsset[] = $state([]);
constructor(manager: SearchResultsManager, currentPage: string, searchTerms: SearchTerms) {
super();
this.#currentPage = currentPage;
this.#manager = manager;
this.#searchTerms = searchTerms;
this.#id = JSON.stringify(searchTerms);
this.#identifier = {
matches(segment: SearchResultsSegment) {
return isEqual(segment.#searchTerms, searchTerms);
},
};
this.initialCount = searchTerms.size || 100;
}
get timelineManager(): SearchResultsManager {
return this.#manager;
}
get identifier(): SegmentIdentifier {
return this.#identifier;
}
get id(): string {
return this.#id;
}
async fetch(): Promise<void> {
if (this.#currentPage === null) {
return;
}
const searchDto: SearchTerms = {
...this.#searchTerms,
withExif: true,
isVisible: true,
page: +this.#currentPage,
};
const { assets } =
('query' in searchDto || 'queryAssetId' in searchDto) && this.#manager.options.isSmartSearchEnabled
? await searchSmart({ smartSearchDto: searchDto })
: await searchAssets({ metadataSearchDto: searchDto });
this.#nextPage = assets.nextPage;
this.#viewerAssets.push(...assets.items.map((asset) => new ViewerAsset(this, toTimelineAsset(asset))));
this.layout();
}
layout(): void {
const timelineAssets = this.#viewerAssets.map((viewerAsset) => viewerAsset.asset);
const layoutOptions = this.timelineManager.createLayoutOptions();
const geometry = getJustifiedLayoutFromAssets(timelineAssets, layoutOptions);
this.height = timelineAssets.length === 0 ? 0 : geometry.containerHeight + this.timelineManager.headerHeight;
for (let i = 0; i < this.#viewerAssets.length; i++) {
const position = getPosition(geometry, i);
this.#viewerAssets[i].position = position;
}
}
get viewerAssets(): ViewerAsset[] {
return this.#viewerAssets;
}
findAssetAbsolutePosition(assetId: string) {
const viewerAsset = this.#viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) {
if (!viewerAsset.position) {
console.warn('No position for asset');
return -1;
}
return this.top + viewerAsset.position.top + this.timelineManager.headerHeight;
}
return -1;
}
loadNextPage() {
if (this.#nextPage !== null) {
if (this.#currentPage === this.#nextPage) {
// there's an active load
return;
}
this.#currentPage = this.#nextPage;
void this.reload(false);
}
}
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
super.updateIntersection({ intersecting, actuallyIntersecting });
const loadNextPage =
this.timelineManager.timelineHeight - this.timelineManager.visibleWindow.bottom < INTERSECTION_EXPAND_BOTTOM;
if (loadNextPage) {
this.loadNextPage();
}
}
}

View File

@@ -13,6 +13,7 @@ export class DayGroup {
readonly monthGroup: MonthGroup;
readonly index: number;
readonly groupTitle: string;
readonly groupTitleFull: string;
readonly day: number;
viewerAssets: ViewerAsset[] = $state([]);
@@ -26,11 +27,12 @@ export class DayGroup {
#col = $state(0);
#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.monthGroup = monthGroup;
this.day = day;
this.groupTitle = groupTitle;
this.groupTitleFull = groupTitleFull;
}
get top() {

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

View File

@@ -25,8 +25,7 @@ export function addAssetsToMonthGroups(
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
if (!month) {
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
month.isLoaded = true;
month = new MonthGroup(timelineManager, asset.localDateTime, 1, true, options.order);
timelineManager.months.push(month);
}

View File

@@ -143,3 +143,79 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
}
}
}
export interface MonthGroupForSearch {
yearMonth: TimelineYearMonth;
top: number;
height: number;
}
export interface BinarySearchResult {
month: TimelineYearMonth;
monthScrollPercent: number;
}
export function findMonthAtScrollPosition(
months: MonthGroupForSearch[],
scrollPosition: number,
maxScrollPercent: number,
): BinarySearchResult | null {
const SUBPIXEL_TOLERANCE = -1; // Tolerance for scroll position checks
const NEAR_END_THRESHOLD = 0.9999; // Threshold for detecting near-end of month
if (months.length === 0) {
return null;
}
// Check if we're before the first month
const firstMonthTop = months[0].top * maxScrollPercent;
if (scrollPosition < firstMonthTop - SUBPIXEL_TOLERANCE) {
return null;
}
// Check if we're after the last month
const lastMonth = months.at(-1)!;
const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent;
if (scrollPosition >= lastMonthBottom - SUBPIXEL_TOLERANCE) {
return null;
}
// Binary search to find the month containing the scroll position
let left = 0;
let right = months.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const month = months[mid];
const monthTop = month.top * maxScrollPercent;
const monthBottom = monthTop + month.height * maxScrollPercent;
if (scrollPosition >= monthTop - SUBPIXEL_TOLERANCE && scrollPosition < monthBottom - SUBPIXEL_TOLERANCE) {
// Found the month containing the scroll position
const distanceIntoMonth = scrollPosition - monthTop;
const monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent));
// Handle month boundary edge case
if (monthScrollPercent > NEAR_END_THRESHOLD && mid < months.length - 1) {
return {
month: months[mid + 1].yearMonth,
monthScrollPercent: 0,
};
}
return {
month: month.yearMonth,
monthScrollPercent,
};
}
if (scrollPosition < monthTop) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// Shouldn't reach here, but return null if we do
return null;
}

View File

@@ -1,22 +1,26 @@
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error';
import {
formatGroupTitle,
formatGroupTitleFull,
formatMonthGroupTitle,
fromTimelinePlainDate,
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
getSegmentIdentifier,
getTimes,
setDifference,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { layoutMonthGroup, updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
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 { DayGroup } from './day-group.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 { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup {
#intersecting: boolean = $state(false);
actuallyIntersecting: boolean = $state(false);
isLoaded: boolean = $state(false);
export class MonthGroup extends PhotostreamSegment {
dayGroups: DayGroup[] = $state([]);
readonly timelineManager: TimelineManager;
#height: number = $state(0);
#top: number = $state(0);
#initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc;
percent: number = $state(0);
assetsCount: number = $derived(
this.isLoaded
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
: this.#initialCount,
);
loader: CancellableTask | undefined;
isHeightActual: boolean = $state(false);
#yearMonth: TimelineYearMonth;
#identifier: SegmentIdentifier;
#timelineManager: TimelineManager;
readonly monthGroupTitle: string;
readonly yearMonth: TimelineYearMonth;
constructor(
store: TimelineManager,
timelineManager: TimelineManager,
yearMonth: TimelineYearMonth,
initialCount: number,
loaded: boolean,
order: AssetOrder = AssetOrder.Desc,
) {
this.timelineManager = store;
this.#initialCount = initialCount;
super();
this.initialCount = initialCount;
this.#yearMonth = yearMonth;
this.#identifier = getSegmentIdentifier(yearMonth);
this.#timelineManager = timelineManager;
this.#sortOrder = order;
this.yearMonth = yearMonth;
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
this.loader = new CancellableTask(
() => {
this.isLoaded = true;
},
() => {
this.dayGroups = [];
this.isLoaded = false;
},
this.#handleLoadError,
);
}
set intersecting(newValue: boolean) {
const old = this.#intersecting;
if (old === newValue) {
return;
}
this.#intersecting = newValue;
if (newValue) {
void this.timelineManager.loadMonthGroup(this.yearMonth);
} else {
this.cancel();
if (loaded) {
this.markLoaded();
}
}
get intersecting() {
return this.#intersecting;
get identifier() {
return this.#identifier;
}
get timelineManager() {
return this.#timelineManager;
}
get yearMonth() {
return this.#yearMonth;
}
fetch(signal: AbortSignal): Promise<void> {
return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal);
}
get lastDayGroup() {
@@ -99,9 +81,9 @@ export class MonthGroup {
return this.dayGroups[0]?.getFirstAsset();
}
getAssets() {
get viewerAssets() {
// 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() {
@@ -222,7 +204,8 @@ export class MonthGroup {
addContext.setDayGroup(dayGroup, localDateTime);
} else {
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);
addContext.setDayGroup(dayGroup, localDateTime);
addContext.newDayGroups.add(dayGroup);
@@ -242,67 +225,15 @@ export class MonthGroup {
return this.getRandomDayGroup()?.getRandomAsset()?.asset;
}
get id() {
return this.viewId;
}
get viewId() {
const { year, month } = this.yearMonth;
return year + '-' + month;
}
set height(height: number) {
if (this.#height === height) {
return;
}
const { timelineManager: store, percent } = this;
const index = store.months.indexOf(this);
const heightDelta = height - this.#height;
this.#height = height;
const prevMonthGroup = store.months[index - 1];
if (prevMonthGroup) {
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
const monthGroup = this.timelineManager.months[cursor];
const newTop = monthGroup.#top + heightDelta;
if (monthGroup.#top !== newTop) {
monthGroup.#top = newTop;
}
}
if (store.topIntersectingMonthGroup) {
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
if (currentIndex > 0) {
if (index < currentIndex) {
store.scrollCompensation = {
heightDelta,
scrollTop: undefined,
monthGroup: this,
};
} else if (percent > 0) {
const top = this.top + height * percent;
store.scrollCompensation = {
heightDelta: undefined,
scrollTop: top,
monthGroup: this,
};
}
}
}
}
get height() {
return this.#height;
}
get top(): number {
return this.#top + this.timelineManager.topSectionHeight;
}
#handleLoadError(error: unknown) {
const _$t = get(t);
handleError(error, _$t('errors.failed_to_load_assets'));
}
findDayGroupForAsset(asset: TimelineAsset) {
for (const group of this.dayGroups) {
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
@@ -316,7 +247,7 @@ export class MonthGroup {
}
findAssetAbsolutePosition(assetId: string) {
this.timelineManager.clearDeferredLayout(this);
this.#clearDeferredLayout();
for (const group of this.dayGroups) {
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) {
@@ -374,4 +305,26 @@ export class MonthGroup {
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,10 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import {
findMonthGroupForAsset,
getMonthGroupByDate,
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
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 { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { TimelineManager } from './timeline-manager.svelte';
@@ -92,7 +95,7 @@ describe('TimelineManager', () => {
});
});
describe('loadMonthGroup', () => {
describe('loadSegment', () => {
let timelineManager: TimelineManager;
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
@@ -128,48 +131,48 @@ describe('TimelineManager', () => {
});
it('loads a month', async () => {
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 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 () => {
await timelineManager.loadMonthGroup({ year: 2023, month: 1 });
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
});
it('cancels month loading', async () => {
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');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
});
it('prevents loading months multiple times', async () => {
await Promise.all([
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 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);
});
it('allows loading a canceled month', async () => {
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();
await loadPromise;
expect(month?.getAssets().length).toEqual(0);
expect(month?.assets.length).toEqual(0);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
expect(month!.getAssets().length).toEqual(3);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(month!.assets.length).toEqual(3);
});
});
@@ -198,7 +201,7 @@ describe('TimelineManager', () => {
expect(timelineManager.months.length).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.month).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
@@ -215,7 +218,7 @@ describe('TimelineManager', () => {
expect(timelineManager.months.length).toEqual(1);
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.month).toEqual(1);
});
@@ -240,10 +243,10 @@ describe('TimelineManager', () => {
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
expect(month).not.toBeNull();
expect(month?.getAssets().length).toEqual(3);
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
expect(month?.assets.length).toEqual(3);
expect(month?.assets[0].id).toEqual(assetOne.id);
expect(month?.assets[1].id).toEqual(assetThree.id);
expect(month?.assets[2].id).toEqual(assetTwo.id);
});
it('orders months by descending date', () => {
@@ -341,14 +344,14 @@ describe('TimelineManager', () => {
timelineManager.addAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1);
timelineManager.updateAssets([updatedAsset]);
expect(timelineManager.months.length).toEqual(2);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.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 })?.getAssets().length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1);
});
});
@@ -374,7 +377,7 @@ describe('TimelineManager', () => {
expect(timelineManager.assetCount).toEqual(2);
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', () => {
@@ -388,7 +391,7 @@ describe('TimelineManager', () => {
expect(timelineManager.assetCount).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', () => {
@@ -477,45 +480,45 @@ describe('TimelineManager', () => {
});
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 a = month!.getAssets()[0];
const b = month!.getAssets()[1];
const a = month!.assets[0];
const b = month!.assets[1];
const previous = await timelineManager.getLaterAsset(b);
expect(previous).toEqual(a);
});
it('returns previous assetId spanning multiple months', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getAssets()[0];
const b = previousMonth!.getAssets()[0];
const a = month!.assets[0];
const b = previousMonth!.assets[0];
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
});
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 previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.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 previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadMonthGroupSpy).toBeCalledTimes(0);
expect(loadSegmentSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
timelineManager.removeAssets([assetTwo.id]);
@@ -523,7 +526,7 @@ describe('TimelineManager', () => {
});
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();
});
});
@@ -556,10 +559,10 @@ describe('TimelineManager', () => {
);
timelineManager.addAssets([assetOne, assetTwo]);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.year).toEqual(2024);
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.month).toEqual(2);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
});
it('ignores removed months', () => {
@@ -576,8 +579,8 @@ describe('TimelineManager', () => {
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
});
});
});

View File

@@ -3,14 +3,18 @@ import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import {
getSegmentIdentifier,
toTimelineAsset,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { clamp, debounce, isEqual } from 'lodash-es';
import { isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import {
addAssetsToMonthGroups,
runAssetOperation,
@@ -32,29 +36,14 @@ import type {
Direction,
ScrubberMonth,
TimelineAsset,
TimelineManagerLayoutOptions,
TimelineManagerOptions,
Viewport,
} from './types';
export class TimelineManager {
isInitialized = $state(false);
months: MonthGroup[] = $state([]);
topSectionHeight = $state(0);
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
export class TimelineManager extends PhotostreamManager {
albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0);
topIntersectingMonthGroup: MonthGroup | undefined = $state();
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
}));
#months: MonthGroup[] = $state([]);
initTask = new CancellableTask(
() => {
@@ -72,121 +61,16 @@ export class TimelineManager {
);
static #INIT_OPTIONS = {};
#viewportHeight = $state(0);
#viewportWidth = $state(0);
#scrollTop = $state(0);
#websocketSupport: WebsocketSupport | undefined;
#rowHeight = $state(235);
#headerHeight = $state(48);
#gap = $state(12);
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
#scrolling = $state(false);
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
scrollCompensation: {
heightDelta: number | undefined;
scrollTop: number | undefined;
monthGroup: MonthGroup | undefined;
} = $state({
heightDelta: 0,
scrollTop: 0,
monthGroup: undefined,
});
constructor() {}
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
let changed = false;
changed ||= this.#setHeaderHeight(headerHeight);
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight);
if (changed) {
this.refreshLayout();
}
get months() {
return this.#months;
}
#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;
get options() {
return this.#options;
}
async *assetsIterator(options?: {
@@ -198,7 +82,7 @@ export class TimelineManager {
const direction = options?.direction ?? 'earlier';
let { startDayGroup, startAsset } = options ?? {};
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 });
startDayGroup = startAsset = undefined;
}
@@ -234,75 +118,24 @@ export class TimelineManager {
this.#websocketSupport = undefined;
}
updateSlidingWindow(scrollTop: number) {
if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop;
this.updateIntersections();
}
}
clearScrollCompensation() {
this.scrollCompensation = {
heightDelta: undefined,
scrollTop: undefined,
monthGroup: undefined,
};
}
updateIntersections() {
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return;
}
let topIntersectingMonthGroup = undefined;
for (const month of this.months) {
updateIntersectionMonthGroup(this, month);
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
topIntersectingMonthGroup = month;
}
}
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
}
for (const month of this.months) {
if (month === this.topIntersectingMonthGroup) {
this.topIntersectingMonthGroup.percent = clamp(
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
0,
1,
);
} else {
month.percent = 0;
}
}
}
clearDeferredLayout(month: MonthGroup) {
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
if (hasDeferred) {
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
for (const group of month.dayGroups) {
group.deferredLayout = false;
}
}
}
async #initializeMonthGroups() {
const timebuckets = await getTimeBuckets({
...authManager.params,
...this.#options,
});
this.months = timebuckets.map((timeBucket) => {
this.#months = timebuckets.map((timeBucket) => {
const date = new SvelteDate(timeBucket.timeBucket);
return new MonthGroup(
this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count,
false,
this.#options.order,
);
});
this.albumAssets.clear();
this.#updateViewportGeometry(false);
this.updateViewportGeometry(false);
}
async updateOptions(options: TimelineManagerOptions) {
@@ -313,16 +146,16 @@ export class TimelineManager {
return;
}
await this.initTask.reset();
await this.#init(options);
this.#updateViewportGeometry(false);
this.#options = options;
await this.init();
this.updateViewportGeometry(false);
}
async #init(options: TimelineManagerOptions) {
async init() {
this.isInitialized = false;
this.months = [];
this.#months = [];
this.albumAssets.clear();
await this.initTask.execute(async () => {
this.#options = options;
await this.#initializeMonthGroups();
}, true);
}
@@ -332,36 +165,8 @@ export class TimelineManager {
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(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();
updateViewportGeometry(changedWidth: boolean) {
super.updateViewportGeometry(changedWidth);
this.#createScrubberMonths();
}
@@ -376,46 +181,13 @@ export class TimelineManager {
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 findSegmentForAssetId(id: string) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
@@ -442,19 +214,14 @@ export class TimelineManager {
}
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
await this.loadMonthGroup(yearMonth, options);
await this.loadSegment(getSegmentIdentifier(yearMonth), options);
return getMonthGroupByDate(this, yearMonth);
}
getMonthGroupByAssetId(assetId: string) {
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
return monthGroupInfo?.monthGroup;
}
async getRandomMonthGroup() {
const random = Math.floor(Math.random() * this.months.length);
const month = this.months[random];
await this.loadMonthGroup(month.yearMonth, { cancelable: false });
await this.loadSegment(getSegmentIdentifier(month.yearMonth), { cancelable: false });
return month;
}
@@ -527,7 +294,7 @@ export class TimelineManager {
if (!monthGroup) {
return;
}
await this.loadMonthGroup(dateTime, { cancelable: false });
await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false });
const asset = monthGroup.findClosest(dateTime);
if (asset) {
return asset;

View File

@@ -1,3 +1,4 @@
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { DayGroup } from './day-group.svelte';
@@ -5,15 +6,20 @@ import { calculateViewerAssetIntersecting } from './internal/intersection-suppor
import type { TimelineAsset } from './types';
export class ViewerAsset {
readonly #group: DayGroup;
readonly #group: DayGroup | PhotostreamSegment;
intersecting = $derived.by(() => {
if (!this.position) {
return false;
}
const store = this.#group.monthGroup.timelineManager;
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
if ((this.#group as DayGroup).sortAssets) {
const dayGroup = this.#group as DayGroup;
const store = dayGroup.monthGroup.timelineManager;
const positionTop = dayGroup.absoluteDayGroupTop + this.position.top;
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
}
const store = (this.#group as PhotostreamSegment).timelineManager;
const positionTop = this.position.top + (this.#group as PhotostreamSegment).top;
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
});
@@ -22,7 +28,7 @@ export class ViewerAsset {
asset: TimelineAsset = <TimelineAsset>$state();
id: string = $derived(this.asset.id);
constructor(group: DayGroup, asset: TimelineAsset) {
constructor(group: DayGroup | PhotostreamSegment, asset: TimelineAsset) {
this.#group = group;
this.asset = asset;
}

View File

@@ -513,7 +513,7 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
try {
for (const monthGroup of timelineManager.months) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
await timelineManager.loadSegment(monthGroup.identifier);
if (!get(isSelectingAllAssets)) {
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 { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
@@ -23,11 +24,19 @@ export type TimelineDateTime = TimelineDate & {
millisecond: number;
};
export type ScrubberListener = (
scrubberMonth: { year: number; month: number },
overallScrollPercent: number,
scrubberMonthScrollPercent: number,
) => void | Promise<void>;
export type ScrubberData = {
scrubberMonth: { year: number; month: number };
overallScrollPercent: number;
scrubberMonthScrollPercent: number;
};
export type ScrubberListener = (scrubberData: ScrubberData) => void | Promise<void>;
export type ScrubberDataWithScrollTo = ScrubberData & {
scrollToFunction: (top: number) => void;
};
export type ScrubberListenerWithScrollTo = (scrubberData: ScrubberDataWithScrollTo) => void | Promise<void>;
// used for AssetResponseDto.dateTimeOriginal, amongst others
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
@@ -151,6 +160,14 @@ export function formatGroupTitle(_date: DateTime): string {
return getDateLocaleString(date, { locale: get(locale) });
}
export const formatGroupTitleFull = (_date: DateTime): string => {
if (!_date.isValid) {
return _date.toString();
}
const date = _date as DateTime<true>;
return getDateLocaleString(date);
};
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
@@ -234,3 +251,11 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
}
return result;
}
export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime) => ({
matches(segment: MonthGroup) {
return (
segment.yearMonth && segment.yearMonth.year === yearMonth.year && segment.yearMonth.month === yearMonth.month
);
},
});

View File

@@ -452,7 +452,7 @@
{isSelectionMode}
{singleSelect}
{showArchiveIcon}
{onSelect}
onAssetSelect={onSelect}
onEscape={handleEscape}
>
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}

View File

@@ -392,7 +392,7 @@
{assetInteraction}
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
onSelect={handleSelectFeaturePhoto}
onAssetSelect={handleSelectFeaturePhoto}
onEscape={handleEscape}
>
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}

View File

@@ -1,17 +1,16 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import SearchResults from '$lib/components/search/SearchResults.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import AssetJobActions from '$lib/components/timeline/actions/AssetJobActions.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
import ChangeDescriptionAction from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
@@ -20,66 +19,44 @@
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type {
PhotostreamSegment,
SegmentIdentifier,
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { parseUtcDate } from '$lib/utils/date-time';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
type AlbumResponseDto,
getPerson,
getTagById,
type MetadataSearchDto,
searchAssets,
searchSmart,
type SmartSearchDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { tick } from 'svelte';
import { getPerson, getTagById, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiPlus, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteMap } from 'svelte/reactivity';
let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
let searchResultsElement: HTMLElement | undefined = $state();
// The GalleryViewer pushes it's own history state, which causes weird
// behavior for history.back(). To prevent that we store the previous page
// manually and navigate back to that.
let previousRoute = $state(AppRoute.EXPLORE as string);
let nextPage = $state(1);
let searchResultAlbums: AlbumResponseDto[] = $state([]);
let searchResultAssets: TimelineAsset[] = $state([]);
let isLoading = $state(true);
let scrollY = $state(0);
let scrollYHistory = 0;
const assetInteraction = new AssetInteraction();
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
let isSmartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
terms;
setTimeout(() => {
handlePromiseError(onSearchQueryUpdate());
});
});
const searchResultsManager = new SearchResultsManager();
let timelineManager = new TimelineManager();
$effect(() => {
void searchResultsManager.updateOptions({ isSmartSearchEnabled, searchTerms: terms });
});
const onEscape = () => {
if ($showAssetViewer) {
@@ -90,91 +67,44 @@
assetInteraction.selectedAssets = [];
return;
}
handlePromiseError(goto(previousRoute));
};
$effect(() => {
if (scrollY) {
scrollYHistory = scrollY;
const restoreMap = new SvelteMap<string, SegmentIdentifier>();
const onAssetDelete = async (assetIds: string[]) => {
for (const id of assetIds) {
const segment = await searchResultsManager.findSegmentForAssetId(id);
if (segment) {
restoreMap.set(id, segment.identifier);
}
}
});
searchResultsManager.removeAssets(assetIds);
};
afterNavigate(({ from }) => {
// Prevent setting previousRoute to the current page.
if (from?.url && from.route.id !== page.route.id) {
previousRoute = from.url.href;
const onUndoDelete = (assets: TimelineAsset[]) => {
const segments: PhotostreamSegment[] = [];
for (const asset of assets) {
const segmentIdentifier = restoreMap.get(asset.id);
if (segmentIdentifier) {
const segment = searchResultsManager.getSegmentByIdentifier(segmentIdentifier);
if (segment) {
segments.push(segment);
}
}
}
const route = from?.route?.id;
if (isPeopleRoute(route)) {
previousRoute = AppRoute.PHOTOS;
for (const segment of segments) {
void segment.reload(false);
}
if (isAlbumsRoute(route)) {
previousRoute = AppRoute.EXPLORE;
}
tick()
.then(() => {
window.scrollTo(0, scrollYHistory);
})
.catch(() => {
// do nothing
});
});
const onAssetDelete = (assetIds: string[]) => {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id));
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
searchResultsManager.removeAssets(assetIds);
assetInteraction.clearMultiselect();
onAssetDelete(assetIds);
};
const handleSelectAll = () => {
assetInteraction.selectAssets(searchResultAssets);
};
async function onSearchQueryUpdate() {
nextPage = 1;
searchResultAssets = [];
searchResultAlbums = [];
await loadNextPage(true);
}
// eslint-disable-next-line svelte/valid-prop-names-in-kit-pages
export const loadNextPage = async (force?: boolean) => {
if (!nextPage || (isLoading && !force)) {
return;
}
isLoading = true;
const searchDto: SearchTerms = {
page: nextPage,
withExif: true,
isVisible: true,
language: $lang,
...terms,
};
try {
const { albums, assets } =
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
? await searchSmart({ smartSearchDto: searchDto })
: await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
nextPage = Number(assets.nextPage) || 0;
} catch (error) {
handleError(error, $t('loading_search_results_failed'));
} finally {
isLoading = false;
}
const allAssets = searchResultsManager.months.flatMap((segment) => segment.assets);
assetInteraction.selectAssets(allAssets);
};
function getHumanReadableDate(dateString: string) {
@@ -248,8 +178,7 @@
cancelMultiselect(assetInteraction);
if (terms.isNotInAlbum.toString() == 'true') {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id));
searchResultsManager.removeAssets(assetIds);
}
};
@@ -258,12 +187,11 @@
}
</script>
<svelte:window bind:scrollY />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<AssetSelectControlBar
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
@@ -281,17 +209,7 @@
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(assetIds, isFavorite) => {
for (const assetId of assetIds) {
const asset = searchResultAssets.find((searchAsset) => searchAsset.id === assetId);
if (asset) {
asset.isFavorite = isFavorite;
}
}
}}
/>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
@@ -301,14 +219,14 @@
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<DeleteAssets menuItem {onAssetDelete} {onUndoDelete} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{:else}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="absolute bg-light"></div>
<div class="w-full flex-1 ps-4">
@@ -319,92 +237,55 @@
{/if}
</section>
{#if terms}
<section
id="search-chips"
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
>
{#each getObjectKeys(terms) as searchKey (searchKey)}
{@const value = terms[searchKey]}
<div class="flex place-content-center place-items-center items-stretch text-xs">
<div
class="flex items-center justify-center bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{value === true ? 'rounded-full' : 'rounded-s-full'}"
>
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
</div>
{#if value !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
{getHumanReadableDate(value)}
{:else if searchKey === 'personIds' && Array.isArray(value)}
{#await getPersonName(value) then personName}
{personName}
{/await}
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
{#await getTagNames(value) then tagNames}
{tagNames}
{/await}
{:else if value === null || value === ''}
{$t('unknown')}
{:else}
{value}
{/if}
</div>
{/if}
</div>
{/each}
</section>
{/if}
<section
class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4 max-h-screen"
class="absolute top-0 right-0 left-0 bottom-0 mb-0 bg-immich-bg dark:bg-immich-dark-bg max-h-screen"
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
bind:this={searchResultsElement}
>
{#if searchResultAlbums.length > 0}
<section>
<div class="uppercase ms-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums')}</div>
<AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
<SearchResults {searchResultsManager} {assetInteraction} stylePaddingHorizontalPx={16}>
{#if terms}
<section
id="search-chips"
class="md:mt-[94px] mt-[72px] mb-4 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap"
>
{#each getObjectKeys(terms) as searchKey (searchKey)}
{@const value = terms[searchKey]}
<div class="flex place-content-center place-items-center items-stretch text-xs">
<div
class="flex items-center justify-center bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{value === true ? 'rounded-full' : 'rounded-s-full'}"
>
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
</div>
<div class="uppercase m-6 text-4xl font-medium text-black/70 dark:text-white/80">
{$t('photos_and_videos')}
</div>
</section>
{/if}
<section id="search-content">
{#if searchResultAssets.length > 0}
<GalleryViewer
assets={searchResultAssets}
{assetInteraction}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}
slidingWindowOffset={searchResultsElement.offsetTop}
/>
{:else if !isLoading}
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center">
<Icon icon={mdiImageOffOutline} size="3.5em" />
<p class="mt-5 text-3xl font-medium">{$t('no_results')}</p>
<p class="text-base font-normal">{$t('no_results_description')}</p>
</div>
</div>
{#if value !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
{getHumanReadableDate(value)}
{:else if searchKey === 'personIds' && Array.isArray(value)}
{#await getPersonName(value) then personName}
{personName}
{/await}
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
{#await getTagNames(value) then tagNames}
{tagNames}
{/await}
{:else if value === null || value === ''}
{$t('unknown')}
{:else}
{value}
{/if}
</div>
{/if}
</div>
{/each}
</section>
{/if}
{#if isLoading}
<div class="flex justify-center py-16 items-center">
<LoadingSpinner size="giant" />
</div>
{/if}
</section>
</SearchResults>
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<AssetSelectControlBar
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
@@ -422,22 +303,12 @@
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => {
for (const id of ids) {
const asset = searchResultAssets.find((asset) => asset.id === id);
if (asset) {
asset.isFavorite = isFavorite;
}
}
}}
/>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeDescriptionAction menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
{#if assetInteraction.isAllUserOwned}
@@ -446,14 +317,14 @@
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<DeleteAssets menuItem {onAssetDelete} {onUndoDelete} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{:else}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="absolute bg-light"></div>
<div class="w-full flex-1 ps-4">

View File

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