Compare commits

..

23 Commits

Author SHA1 Message Date
Thomas 412633d0aa Merge branch 'main' into feat-no-thumbhash-cache 2025-09-15 15:30:09 +01:00
shenlong dcee34095b fix: reset sqlite on beta migration (#20735)
reset sync stream on migration

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-14 16:30:25 -05:00
Alex 15f182902f fix: check if preferencesStore is defined (#21958) 2025-09-14 20:30:15 +00:00
shenlong b26b452530 fix: do not listen for store updates in isolates (#21947)
* dispose store on isolate cleanup

* do not listen for store updates in isolates

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-09-14 14:50:17 -05:00
shenlong 2dcb32f7d0 chore: update background downloader (#21909)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-14 14:44:48 -05:00
Brandon Wees 27d2f3efe2 feat: disable snapping when a timeline has less than 12 months (#21649)
* feat: disable snapping when a timeline has less than 12 months

* fix: disable placeholders when not snapping

also moved month constant to constants.dart

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-09-14 19:24:52 +00:00
shenlong d38468439b fix: complete does not destroy engine on close (#21943)
* fix: complete does not destroy engine on close

* reset flutterApi on cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-14 14:17:12 -05:00
Alex 0166e99d90 chore: remove main timeline query watch throttle (#21942) 2025-09-14 02:09:07 -05:00
Alex 71e33e35dc chore: check before sync linked albums from websocket events (#21941) 2025-09-14 02:08:41 -05:00
Mert a122d4b969 fix(mobile): double hero animation (#21927)
fix double hero animation
2025-09-13 16:47:07 -05:00
shenlong dad81af6e3 fix: show view in timeline from search page (#21873)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-12 22:44:31 -05:00
shenlong ac6b42e1e8 fix: do not show stack action if there is only one selection (#21868)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-12 22:43:51 -05:00
Stewart Rand 4059638151 fix: context menu jank (#21844)
* Fix issue with context menu jank by only applying overflow styling when transition is complete

* Remove comment

Co-authored-by: Alex <alex.tran1502@gmail.com>

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-09-12 22:43:22 -05:00
Stewart Rand 1823a28e59 chore: improve date text slide-in transition (#21879)
* Make date text slide-in transition smooth

* fix: lint

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-09-13 03:42:42 +00:00
Stewart Rand b6bf1852cd fix: keep adequate space around page title (#21881)
Keep space around page title
2025-09-12 22:42:25 -05:00
Stewart Rand cdc26f2c7b fix: z-index of top bar on show/hide people view (#21847)
Fix z-index of top bar on show/hide people view
2025-09-12 22:32:50 -05:00
Alex 913b3789cc chore: simplify timeline switcher toggle (#21864)
chore: timeline switcher option simplify
2025-09-12 22:32:15 -05:00
Stewart Rand 994a770921 chore: improve context button accessibility (#21876)
Make context menu button filled on album list and faces page
2025-09-12 22:31:52 -05:00
Mert 17bbcdf584 chore(mobile): add debugPrint lint rule (#21872)
* add lint rule

* update usages

* stragglers

* use dcm

* formatting

* test ci

* Revert "test ci"

This reverts commit 8f864c4e4d.

* revert whitespace change
2025-09-12 18:56:00 -04:00
bo0tzz 23aa661324 fix: use mdq image with jq (#21860) 2025-09-12 21:46:39 +02:00
Min Idzelis a10a946d1a fix: let dev docker compose service runs as root (#21579) 2025-09-12 16:20:41 +01:00
Stewart Rand 04c9531624 fix: format point count numbers on map view (#21848)
Format numbers on map view
2025-09-12 07:20:05 +00:00
Thomas Way 2bba33f834 feat(web): don't animate cached thumbnails
Thumbnails for assets always are displayed with a thumbhash which fades out
over 100ms, even if the thumbnail is cached and ready immediately. This can be
a bit distracting and make Immich feel 'slow', or inefficient as it feels like
the thumbnails are always being reloaded. Skipping the thumbhash and animation
for cached thumbnails makes it feel much more responsive.
2025-08-10 20:59:02 +01:00
117 changed files with 1819 additions and 2505 deletions
@@ -26,7 +26,7 @@ services:
env_file: !reset [] env_file: !reset []
init: init:
env_file: !reset [] env_file: !reset []
command: sh -c 'find /data -maxdepth 1 ! -path "/data/postgres" -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done' command: sh -c 'find /data -maxdepth 1 ! -path "/data/postgres" -type d -exec chown ${UID:-0}:${GID:-0} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-0}:${GID:-0} "$$path" || true; done'
immich-machine-learning: immich-machine-learning:
env_file: !reset [] env_file: !reset []
database: database:
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run] needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }} if: ${{ needs.should_run.outputs.should_run == 'true' }}
container: container:
image: yshavit/mdq:0.9.0@sha256:4399483ca857fb1a7ed28a596f754c7373e358647de31ce14b79a27c91e1e35e image: ghcr.io/immich-app/mdq:main@sha256:1669c75a5542333ff6b03c13d5fd259ea8d798188b84d5d99093d62e4542eb05
outputs: outputs:
checked: ${{ steps.get_checkbox.outputs.checked }} checked: ${{ steps.get_checkbox.outputs.checked }}
steps: steps:
+1 -1
View File
@@ -50,7 +50,7 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.tabSize": 2 "editor.tabSize": 2
}, },
"cSpell.words": ["immich", "intersectable", "intersectables"], "cSpell.words": ["immich"],
"editor.formatOnSave": true, "editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"], "eslint.validate": ["javascript", "svelte"],
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
+3 -5
View File
@@ -70,11 +70,9 @@ VOLUME_DIRS = \
# Helper function to chown, on error suggest remediation and exit # Helper function to chown, on error suggest remediation and exit
define safe_chown define safe_chown
if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \ CURRENT_OWNER=$$(stat -c '%u:%g' "$(1)" 2>/dev/null || echo "none"); \
true; \ DESIRED_OWNER="$(or $(UID),0):$(or $(GID),0)"; \
else \ if [ "$$CURRENT_OWNER" != "$$DESIRED_OWNER" ] && ! chown -v $(2) $$DESIRED_OWNER "$(1)" 2>/dev/null; then \
STATUS=$$?; echo "Exit code: $$STATUS $(1)"; \
echo "$$STATUS $(1)"; \
echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \ echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \
exit 1; \ exit 1; \
fi; fi;
+3 -3
View File
@@ -21,7 +21,7 @@ services:
# extends: # extends:
# file: hwaccel.transcoding.yml # file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
user: '${UID:-1000}:${GID:-1000}' user: '${UID:-0}:${GID:-0}'
build: build:
context: ../ context: ../
dockerfile: server/Dockerfile dockerfile: server/Dockerfile
@@ -82,7 +82,7 @@ services:
image: immich-web-dev:latest image: immich-web-dev:latest
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919 # Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
# user: 0:0 # user: 0:0
user: '${UID:-1000}:${GID:-1000}' user: '${UID:-0}:${GID:-0}'
build: build:
context: ../ context: ../
dockerfile: server/Dockerfile dockerfile: server/Dockerfile
@@ -189,7 +189,7 @@ services:
env_file: env_file:
- .env - .env
user: 0:0 user: 0:0
command: sh -c 'find /data -maxdepth 1 -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done' command: sh -c 'find /data -maxdepth 1 -type d -exec chown ${UID:-0}:${GID:-0} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-0}:${GID:-0} "$$path" || true; done'
volumes: volumes:
- pnpm-store:/usr/src/app/.pnpm-store - pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules - server-node_modules:/usr/src/app/server/node_modules
-2
View File
@@ -1339,8 +1339,6 @@
"my_albums": "My albums", "my_albums": "My albums",
"name": "Name", "name": "Name",
"name_or_nickname": "Name or nickname", "name_or_nickname": "Name or nickname",
"navigate": "Navigate",
"navigate_to_time": "Navigate to Time",
"network_requirement_photos_upload": "Use cellular data to backup photos", "network_requirement_photos_upload": "Use cellular data to backup photos",
"network_requirement_videos_upload": "Use cellular data to backup videos", "network_requirement_videos_upload": "Use cellular data to backup videos",
"network_requirements_updated": "Network requirements changed, resetting backup queue", "network_requirements_updated": "Network requirements changed, resetting backup queue",
+7
View File
@@ -134,6 +134,13 @@ custom_lint:
dart_code_metrics: dart_code_metrics:
rules: rules:
- banned-usage:
entries:
- name: debugPrint
description: Use dPrint instead of debugPrint for proper tree-shaking in release builds.
exclude-paths:
- 'lib/utils/debug_print.dart'
severity: perf
# All rules from "recommended" preset # All rules from "recommended" preset
# Show potential errors # Show potential errors
# - avoid-cascade-after-if-null # - avoid-cascade-after-if-null
@@ -133,7 +133,6 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
return return
} }
isComplete = true
flutterApi?.cancel { result in flutterApi?.cancel { result in
self.complete(success: false) self.complete(success: false)
} }
@@ -174,6 +173,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
isComplete = true isComplete = true
engine.destroyContext() engine.destroyContext()
flutterApi = nil
completionHandler(success) completionHandler(success)
} }
} }
+2
View File
@@ -45,3 +45,5 @@ const List<(String, String)> kWidgetNames = [
const double kUploadStatusFailed = -1.0; const double kUploadStatusFailed = -1.0;
const double kUploadStatusCanceled = -2.0; const double kUploadStatusCanceled = -2.0;
const int kMinMonthsToEnableScrubberSnap = 12;
+3 -1
View File
@@ -77,7 +77,9 @@ enum StoreKey<T> {
enableBackup<bool>._(1003), enableBackup<bool>._(1003),
useWifiForUploadVideos<bool>._(1004), useWifiForUploadVideos<bool>._(1004),
useWifiForUploadPhotos<bool>._(1005), useWifiForUploadPhotos<bool>._(1005),
needBetaMigration<bool>._(1006); needBetaMigration<bool>._(1006),
// TODO: Remove this after patching open-api
shouldResetSync<bool>._(1007);
const StoreKey._(this.id); const StoreKey._(this.id);
final int id; final int id;
@@ -7,6 +7,8 @@ import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/intl_keys.g.dart'; import 'package:immich_mobile/generated/intl_keys.g.dart';
@@ -27,6 +29,7 @@ import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -159,7 +162,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
try { try {
await _cleanup(); await _cleanup();
} catch (error, stack) { } catch (error, stack) {
debugPrint('Failed to cleanup background worker: $error with stack: $stack'); dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
} }
} }
@@ -180,6 +183,8 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
// Discard any errors on the dispose call // Discard any errors on the dispose call
return; return;
}), }),
LogService.I.dispose(),
Store.dispose(),
_drift.close(), _drift.close(),
_driftLogger.close(), _driftLogger.close(),
backgroundSyncManager.cancel(), backgroundSyncManager.cancel(),
@@ -192,7 +197,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
await Future.wait(cleanupFutures); await Future.wait(cleanupFutures);
_logger.info("Background worker resources cleaned up"); _logger.info("Background worker resources cleaned up");
} catch (error, stack) { } catch (error, stack) {
debugPrint('Failed to cleanup background worker: $error with stack: $stack'); dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
} }
} }
@@ -230,7 +235,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
.startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken); .startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken);
}, },
(error, stack) { (error, stack) {
debugPrint("Error in backup zone $error, $stack"); dPrint(() => "Error in backup zone $error, $stack");
}, },
); );
} }
@@ -268,6 +273,6 @@ Future<void> backgroundSyncNativeEntrypoint() async {
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
final (isar, drift, logDB) = await Bootstrap.initDB(); final (isar, drift, logDB) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDB, shouldBufferLogs: false); await Bootstrap.initDomain(isar, drift, logDB, shouldBufferLogs: false, listenStoreUpdates: false);
await BackgroundWorkerBgService(isar: isar, drift: drift, driftLogger: logDB).init(); await BackgroundWorkerBgService(isar: isar, drift: drift, driftLogger: logDB).init();
} }
+3 -4
View File
@@ -1,11 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
/// Service responsible for handling application logging. /// Service responsible for handling application logging.
@@ -66,13 +66,12 @@ class LogService {
} }
void _handleLogRecord(LogRecord r) { void _handleLogRecord(LogRecord r) {
if (kDebugMode) { dPrint(
debugPrint( () =>
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}' '[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}'
'${r.error == null ? '' : '\nError: ${r.error}'}' '${r.error == null ? '' : '\nError: ${r.error}'}'
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}', '${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
); );
}
final record = LogMessage( final record = LogMessage(
message: r.message, message: r.message,
@@ -1,7 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class DriftPartnerService { class DriftPartnerService {
final DriftPartnerRepository _driftPartnerRepository; final DriftPartnerRepository _driftPartnerRepository;
@@ -30,7 +30,7 @@ class DriftPartnerService {
Future<void> toggleShowInTimeline(String partnerId, String userId) async { Future<void> toggleShowInTimeline(String partnerId, String userId) async {
final partner = await _driftPartnerRepository.getPartner(partnerId, userId); final partner = await _driftPartnerRepository.getPartner(partnerId, userId);
if (partner == null) { if (partner == null) {
debugPrint("Partner not found: $partnerId for user: $userId"); dPrint(() => "Partner not found: $partnerId for user: $userId");
return; return;
} }
@@ -10,7 +10,7 @@ class StoreService {
/// In-memory cache. Keys are [StoreKey.id] /// In-memory cache. Keys are [StoreKey.id]
final Map<int, Object?> _cache = {}; final Map<int, Object?> _cache = {};
late final StreamSubscription<List<StoreDto>> _storeUpdateSubscription; StreamSubscription<List<StoreDto>>? _storeUpdateSubscription;
StoreService._({required IStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository; StoreService._({required IStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository;
@@ -24,15 +24,17 @@ class StoreService {
} }
// TODO: Replace the implementation with the one from create after removing the typedef // TODO: Replace the implementation with the one from create after removing the typedef
static Future<StoreService> init({required IStoreRepository storeRepository}) async { static Future<StoreService> init({required IStoreRepository storeRepository, bool listenUpdates = true}) async {
_instance ??= await create(storeRepository: storeRepository); _instance ??= await create(storeRepository: storeRepository, listenUpdates: listenUpdates);
return _instance!; return _instance!;
} }
static Future<StoreService> create({required IStoreRepository storeRepository}) async { static Future<StoreService> create({required IStoreRepository storeRepository, bool listenUpdates = true}) async {
final instance = StoreService._(isarStoreRepository: storeRepository); final instance = StoreService._(isarStoreRepository: storeRepository);
await instance.populateCache(); await instance.populateCache();
if (listenUpdates) {
instance._storeUpdateSubscription = instance._listenForChange(); instance._storeUpdateSubscription = instance._listenForChange();
}
return instance; return instance;
} }
@@ -50,8 +52,8 @@ class StoreService {
}); });
/// Disposes the store and cancels the subscription. To reuse the store call init() again /// Disposes the store and cancels the subscription. To reuse the store call init() again
void dispose() async { Future<void> dispose() async {
await _storeUpdateSubscription.cancel(); await _storeUpdateSubscription?.cancel();
_cache.clear(); _cache.clear();
} }
@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
@@ -6,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final syncLinkedAlbumServiceProvider = Provider( final syncLinkedAlbumServiceProvider = Provider(
(ref) => SyncLinkedAlbumService( (ref) => SyncLinkedAlbumService(
@@ -100,7 +100,7 @@ class SyncLinkedAlbumService {
/// Creates a new remote album and links it to the local album /// Creates a new remote album and links it to the local album
Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async { Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async {
debugPrint("Creating new remote album for local album: ${localAlbum.name}"); dPrint(() => "Creating new remote album for local album: ${localAlbum.name}");
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, assetIds: []); final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, assetIds: []);
await _remoteAlbumRepository.create(newRemoteAlbum, []); await _remoteAlbumRepository.create(newRemoteAlbum, []);
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id); return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
@@ -29,6 +29,7 @@ class SyncStreamService {
bool shouldReset = false; bool shouldReset = false;
await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true); await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true);
if (shouldReset) { if (shouldReset) {
_logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges(_handleEvents); await _syncApiRepository.streamChanges(_handleEvents);
} }
} }
+18 -5
View File
@@ -100,8 +100,14 @@ class BackgroundSyncManager {
// We use a ternary operator to avoid [_deviceAlbumSyncTask] from being // We use a ternary operator to avoid [_deviceAlbumSyncTask] from being
// captured by the closure passed to [runInIsolateGentle]. // captured by the closure passed to [runInIsolateGentle].
_deviceAlbumSyncTask = full _deviceAlbumSyncTask = full
? runInIsolateGentle(computation: (ref) => ref.read(localSyncServiceProvider).sync(full: true)) ? runInIsolateGentle(
: runInIsolateGentle(computation: (ref) => ref.read(localSyncServiceProvider).sync(full: false)); computation: (ref) => ref.read(localSyncServiceProvider).sync(full: true),
debugLabel: 'local-sync-full-true',
)
: runInIsolateGentle(
computation: (ref) => ref.read(localSyncServiceProvider).sync(full: false),
debugLabel: 'local-sync-full-false',
);
return _deviceAlbumSyncTask! return _deviceAlbumSyncTask!
.whenComplete(() { .whenComplete(() {
@@ -122,7 +128,10 @@ class BackgroundSyncManager {
onHashingStart?.call(); onHashingStart?.call();
_hashTask = runInIsolateGentle(computation: (ref) => ref.read(hashServiceProvider).hashAssets()); _hashTask = runInIsolateGentle(
computation: (ref) => ref.read(hashServiceProvider).hashAssets(),
debugLabel: 'hash-assets',
);
return _hashTask! return _hashTask!
.whenComplete(() { .whenComplete(() {
@@ -142,7 +151,10 @@ class BackgroundSyncManager {
onRemoteSyncStart?.call(); onRemoteSyncStart?.call();
_syncTask = runInIsolateGentle(computation: (ref) => ref.read(syncStreamServiceProvider).sync()); _syncTask = runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
debugLabel: 'remote-sync',
);
return _syncTask! return _syncTask!
.whenComplete(() { .whenComplete(() {
onRemoteSyncComplete?.call(); onRemoteSyncComplete?.call();
@@ -169,7 +181,7 @@ class BackgroundSyncManager {
return _linkedAlbumSyncTask!.future; return _linkedAlbumSyncTask!.future;
} }
_linkedAlbumSyncTask = runInIsolateGentle(computation: syncLinkedAlbumsIsolated); _linkedAlbumSyncTask = runInIsolateGentle(computation: syncLinkedAlbumsIsolated, debugLabel: 'linked-album-sync');
return _linkedAlbumSyncTask!.whenComplete(() { return _linkedAlbumSyncTask!.whenComplete(() {
_linkedAlbumSyncTask = null; _linkedAlbumSyncTask = null;
}); });
@@ -178,4 +190,5 @@ class BackgroundSyncManager {
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle( Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData), computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData),
debugLabel: 'websocket-batch',
); );
@@ -1,6 +1,7 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:intl/message_format.dart'; import 'package:intl/message_format.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/debug_print.dart';
extension StringTranslateExtension on String { extension StringTranslateExtension on String {
String t({BuildContext? context, Map<String, Object>? args}) { String t({BuildContext? context, Map<String, Object>? args}) {
@@ -39,7 +40,7 @@ String _translateHelper(BuildContext? context, String key, [Map<String, Object>?
? MessageFormat(translatedMessage, locale: Intl.defaultLocale ?? 'en').format(args) ? MessageFormat(translatedMessage, locale: Intl.defaultLocale ?? 'en').format(args)
: translatedMessage; : translatedMessage;
} catch (e) { } catch (e) {
debugPrint('Translation failed for key "$key". Error: $e'); dPrint(() => 'Translation failed for key "$key". Error: $e');
return key; return key;
} }
} }
@@ -69,6 +69,29 @@ class Drift extends $Drift implements IDatabaseRepository {
Drift([QueryExecutor? executor]) Drift([QueryExecutor? executor])
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true))); : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
Future<void> reset() async {
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
await exclusively(() async {
// https://stackoverflow.com/a/65743498/25690041
await customStatement('PRAGMA writable_schema = 1;');
await customStatement('DELETE FROM sqlite_master;');
await customStatement('VACUUM;');
await customStatement('PRAGMA writable_schema = 0;');
await customStatement('PRAGMA integrity_check');
await customStatement('PRAGMA user_version = 0');
await beforeOpen(
// ignore: invalid_use_of_internal_member
resolvedEngine.executor,
OpeningDetails(null, schemaVersion),
);
await customStatement('PRAGMA user_version = $schemaVersion');
// Refresh all stream queries
notifyUpdates({for (final table in allTables) TableUpdate.onTable(table)});
});
}
@override @override
int get schemaVersion => 10; int get schemaVersion => 10;
@@ -3,7 +3,9 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -33,6 +35,7 @@ class SyncApiRepository {
await _api.applyToParams([], headerParams); await _api.applyToParams([], headerParams);
headers.addAll(headerParams); headers.addAll(headerParams);
final shouldReset = Store.get(StoreKey.shouldResetSync, false);
final request = http.Request('POST', Uri.parse(endpoint)); final request = http.Request('POST', Uri.parse(endpoint));
request.headers.addAll(headers); request.headers.addAll(headers);
request.body = jsonEncode( request.body = jsonEncode(
@@ -58,6 +61,7 @@ class SyncApiRepository {
SyncRequestType.peopleV1, SyncRequestType.peopleV1,
SyncRequestType.assetFacesV1, SyncRequestType.assetFacesV1,
], ],
reset: shouldReset,
).toJson(), ).toJson(),
); );
@@ -81,6 +85,9 @@ class SyncApiRepository {
throw ApiException(response.statusCode, 'Failed to get sync stream: $errorBody'); throw ApiException(response.statusCode, 'Failed to get sync stream: $errorBody');
} }
// Reset after successful stream start
await Store.put(StoreKey.shouldResetSync, false);
await for (final chunk in response.stream.transform(utf8.decoder)) { await for (final chunk in response.stream.transform(utf8.decoder)) {
if (shouldAbort) { if (shouldAbort) {
break; break;
@@ -42,14 +42,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
throw UnsupportedError("GroupAssetsBy.none is not supported for watchMainBucket"); throw UnsupportedError("GroupAssetsBy.none is not supported for watchMainBucket");
} }
return _db.mergedAssetDrift return _db.mergedAssetDrift.mergedBucket(userIds: userIds, groupBy: groupBy.index).map((row) {
.mergedBucket(userIds: userIds, groupBy: groupBy.index)
.map((row) {
final date = row.bucketDate.dateFmt(groupBy); final date = row.bucketDate.dateFmt(groupBy);
return TimeBucket(date: date, assetCount: row.assetCount); return TimeBucket(date: date, assetCount: row.assetCount);
}) }).watch();
.watch()
.throttle(const Duration(seconds: 3), trailing: true);
} }
Future<List<BaseAsset>> _getMainBucketAssets(List<String> userIds, {required int offset, required int count}) { Future<List<BaseAsset>> _getMainBucketAssets(List<String> userIds, {required int offset, required int count}) {
+10 -9
View File
@@ -39,6 +39,7 @@ import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart'; import 'package:timezone/data/latest.dart';
import 'package:worker_manager/worker_manager.dart'; import 'package:worker_manager/worker_manager.dart';
import 'package:immich_mobile/utils/debug_print.dart';
void main() async { void main() async {
ImmichWidgetsBinding(); ImmichWidgetsBinding();
@@ -69,9 +70,9 @@ Future<void> initApp() async {
if (kReleaseMode && Platform.isAndroid) { if (kReleaseMode && Platform.isAndroid) {
try { try {
await FlutterDisplayMode.setHighRefreshRate(); await FlutterDisplayMode.setHighRefreshRate();
debugPrint("Enabled high refresh mode"); dPrint(() => "Enabled high refresh mode");
} catch (e) { } catch (e) {
debugPrint("Error setting high refresh rate: $e"); dPrint(() => "Error setting high refresh rate: $e");
} }
} }
@@ -126,23 +127,23 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) { switch (state) {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
debugPrint("[APP STATE] resumed"); dPrint(() => "[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume(); ref.read(appStateProvider.notifier).handleAppResume();
break; break;
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
debugPrint("[APP STATE] inactive"); dPrint(() => "[APP STATE] inactive");
ref.read(appStateProvider.notifier).handleAppInactivity(); ref.read(appStateProvider.notifier).handleAppInactivity();
break; break;
case AppLifecycleState.paused: case AppLifecycleState.paused:
debugPrint("[APP STATE] paused"); dPrint(() => "[APP STATE] paused");
ref.read(appStateProvider.notifier).handleAppPause(); ref.read(appStateProvider.notifier).handleAppPause();
break; break;
case AppLifecycleState.detached: case AppLifecycleState.detached:
debugPrint("[APP STATE] detached"); dPrint(() => "[APP STATE] detached");
ref.read(appStateProvider.notifier).handleAppDetached(); ref.read(appStateProvider.notifier).handleAppDetached();
break; break;
case AppLifecycleState.hidden: case AppLifecycleState.hidden:
debugPrint("[APP STATE] hidden"); dPrint(() => "[APP STATE] hidden");
ref.read(appStateProvider.notifier).handleAppHidden(); ref.read(appStateProvider.notifier).handleAppHidden();
break; break;
} }
@@ -200,7 +201,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
@override @override
initState() { initState() {
super.initState(); super.initState();
initApp().then((_) => debugPrint("App Init Completed")); initApp().then((_) => dPrint(() => "App Init Completed"));
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// needs to be delayed so that EasyLocalization is working // needs to be delayed so that EasyLocalization is working
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
@@ -239,7 +240,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale), theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
routerConfig: router.config( routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder, deepLinkBuilder: _deepLinkBuilder,
navigatorObservers: () => [AppNavigationObserver(ref: ref), HeroController()], navigatorObservers: () => [AppNavigationObserver(ref: ref)],
), ),
), ),
); );
@@ -91,6 +91,8 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
ref.read(websocketProvider.notifier).stopListenToOldEvents(); ref.read(websocketProvider.notifier).stopListenToOldEvents();
ref.read(websocketProvider.notifier).startListeningToBetaEvents(); ref.read(websocketProvider.notifier).startListeningToBetaEvents();
await ref.read(driftProvider).reset();
await Store.put(StoreKey.shouldResetSync, true);
final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (permission.isGranted) { if (permission.isGranted) {
+11 -23
View File
@@ -12,7 +12,6 @@ import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewe
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart';
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
import 'package:immich_mobile/widgets/settings/language_settings.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
import 'package:immich_mobile/widgets/settings/notification_setting.dart'; import 'package:immich_mobile/widgets/settings/notification_setting.dart';
@@ -20,7 +19,6 @@ import 'package:immich_mobile/widgets/settings/preference_settings/preference_se
import 'package:immich_mobile/widgets/settings/settings_card.dart'; import 'package:immich_mobile/widgets/settings/settings_card.dart';
enum SettingSection { enum SettingSection {
beta('sync_status', Icons.sync_outlined, "sync_status_subtitle"),
advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"), advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"),
assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"), assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"),
backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"), backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"),
@@ -28,14 +26,14 @@ enum SettingSection {
networking('networking_settings', Icons.wifi, "networking_subtitle"), networking('networking_settings', Icons.wifi, "networking_subtitle"),
notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"), notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"),
preferences('preferences_settings_title', Icons.interests_outlined, "preferences_settings_subtitle"), preferences('preferences_settings_title', Icons.interests_outlined, "preferences_settings_subtitle"),
timeline('asset_list_settings_title', Icons.auto_awesome_mosaic_outlined, "asset_list_settings_subtitle"); timeline('asset_list_settings_title', Icons.auto_awesome_mosaic_outlined, "asset_list_settings_subtitle"),
beta('sync_status', Icons.sync_outlined, "sync_status_subtitle");
final String title; final String title;
final String subtitle; final String subtitle;
final IconData icon; final IconData icon;
Widget get widget => switch (this) { Widget get widget => switch (this) {
SettingSection.beta => const _BetaLandscapeToggle(),
SettingSection.advanced => const AdvancedSettings(), SettingSection.advanced => const AdvancedSettings(),
SettingSection.assetViewer => const AssetViewerSettings(), SettingSection.assetViewer => const AssetViewerSettings(),
SettingSection.backup => SettingSection.backup =>
@@ -45,6 +43,7 @@ enum SettingSection {
SettingSection.notifications => const NotificationSetting(), SettingSection.notifications => const NotificationSetting(),
SettingSection.preferences => const PreferenceSetting(), SettingSection.preferences => const PreferenceSetting(),
SettingSection.timeline => const AssetListSettings(), SettingSection.timeline => const AssetListSettings(),
SettingSection.beta => const SyncStatusAndActions(),
}; };
const SettingSection(this.title, this.icon, this.subtitle); const SettingSection(this.title, this.icon, this.subtitle);
@@ -59,7 +58,7 @@ class SettingsPage extends StatelessWidget {
context.locale; context.locale;
return Scaffold( return Scaffold(
appBar: AppBar(centerTitle: false, title: const Text('settings').tr()), appBar: AppBar(centerTitle: false, title: const Text('settings').tr()),
body: context.isMobile ? const _MobileLayout() : const _TabletLayout(), body: context.isMobile ? const SafeArea(child: _MobileLayout()) : const SafeArea(child: _TabletLayout()),
); );
} }
} }
@@ -72,7 +71,6 @@ class _MobileLayout extends StatelessWidget {
.expand( .expand(
(setting) => setting == SettingSection.beta (setting) => setting == SettingSection.beta
? [ ? [
const BetaTimelineListTile(),
if (Store.isBetaTimelineEnabled) if (Store.isBetaTimelineEnabled)
SettingsCard( SettingsCard(
icon: Icons.sync_outlined, icon: Icons.sync_outlined,
@@ -93,7 +91,7 @@ class _MobileLayout extends StatelessWidget {
.toList(); .toList();
return ListView( return ListView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(top: 10.0, bottom: 56), padding: const EdgeInsets.only(top: 10.0, bottom: 16),
children: [...settings], children: [...settings],
); );
} }
@@ -134,21 +132,6 @@ class _TabletLayout extends HookWidget {
} }
} }
class _BetaLandscapeToggle extends HookWidget {
const _BetaLandscapeToggle();
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(height: 100, child: BetaTimelineListTile()),
if (Store.isBetaTimelineEnabled) const Expanded(child: SyncStatusAndActions()),
],
);
}
}
@RoutePage() @RoutePage()
class SettingsSubPage extends StatelessWidget { class SettingsSubPage extends StatelessWidget {
const SettingsSubPage(this.section, {super.key}); const SettingsSubPage(this.section, {super.key});
@@ -158,9 +141,14 @@ class SettingsSubPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
context.locale; context.locale;
return Scaffold( return SafeArea(
bottom: true,
top: false,
right: true,
child: Scaffold(
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()), appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
body: section.widget, body: section.widget,
),
); );
} }
} }
@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@RoutePage() @RoutePage()
class DriftPartnerDetailPage extends StatelessWidget { class DriftPartnerDetailPage extends StatelessWidget {
@@ -68,7 +69,7 @@ class _InfoBoxState extends ConsumerState<_InfoBox> {
_inTimeline = !_inTimeline; _inTimeline = !_inTimeline;
}); });
} catch (error, stack) { } catch (error, stack) {
debugPrint("Failed to toggle in timeline: $error $stack"); dPrint(() => "Failed to toggle in timeline: $error $stack");
ImmichToast.show( ImmichToast.show(
context: context, context: context,
toastType: ToastType.error, toastType: ToastType.error,
@@ -14,9 +14,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@@ -38,8 +39,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final previousRouteName = ref.watch(previousRouteNameProvider); final previousRouteName = ref.watch(previousRouteNameProvider);
final tabRoute = ref.watch(tabProvider);
final showViewInTimelineButton = final showViewInTimelineButton =
previousRouteName != TabShellRoute.name && (previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) &&
previousRouteName != AssetViewerRoute.name && previousRouteName != AssetViewerRoute.name &&
previousRouteName != null; previousRouteName != null;
@@ -43,7 +43,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const EditDateTimeActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
@@ -43,7 +43,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const EditDateTimeActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -104,7 +104,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const EditDateTimeActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline), : const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
@@ -100,7 +100,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const EditDateTimeActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:scroll_date_picker/scroll_date_picker.dart'; import 'package:scroll_date_picker/scroll_date_picker.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget { class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget {
final DriftPerson person; final DriftPerson person;
@@ -36,7 +37,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
context.pop<DateTime>(_selectedDate); context.pop<DateTime>(_selectedDate);
} }
} catch (error) { } catch (error) {
debugPrint('Error updating birthday: $error'); dPrint(() => 'Error updating birthday: $error');
if (!context.mounted) { if (!context.mounted) {
return; return;
@@ -7,6 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class DriftPersonNameEditForm extends ConsumerStatefulWidget { class DriftPersonNameEditForm extends ConsumerStatefulWidget {
final DriftPerson person; final DriftPerson person;
@@ -34,7 +35,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonNameEditFor
context.pop<String>(newName); context.pop<String>(newName);
} }
} catch (error) { } catch (error) {
debugPrint('Error updating name: $error'); dPrint(() => 'Error updating name: $error');
if (!context.mounted) { if (!context.mounted) {
return; return;
@@ -3,14 +3,15 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:intl/intl.dart' hide TextDirection;
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView. /// for quick navigation of the BoxScrollView.
@@ -79,6 +80,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
double _thumbTopOffset = 0.0; double _thumbTopOffset = 0.0;
bool _isDragging = false; bool _isDragging = false;
List<_Segment> _segments = []; List<_Segment> _segments = [];
int _monthCount = 0;
late AnimationController _thumbAnimationController; late AnimationController _thumbAnimationController;
Timer? _fadeOutTimer; Timer? _fadeOutTimer;
@@ -105,6 +107,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration); _thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
_thumbAnimation = CurvedAnimation(parent: _thumbAnimationController, curve: Curves.fastEaseInToSlowEaseOut); _thumbAnimation = CurvedAnimation(parent: _thumbAnimationController, curve: Curves.fastEaseInToSlowEaseOut);
_labelAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration); _labelAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
_monthCount = getMonthCount();
_labelAnimation = CurvedAnimation(parent: _labelAnimationController, curve: Curves.fastOutSlowIn); _labelAnimation = CurvedAnimation(parent: _labelAnimationController, curve: Curves.fastOutSlowIn);
} }
@@ -121,6 +124,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) { if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight); _segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_monthCount = getMonthCount();
} }
} }
@@ -140,6 +144,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}); });
} }
int getMonthCount() {
return _segments.map((e) => "${e.date.month}_${e.date.year}").toSet().length;
}
bool _onScrollNotification(ScrollNotification notification) { bool _onScrollNotification(ScrollNotification notification) {
if (_isDragging) { if (_isDragging) {
// If the user is dragging the thumb, we don't want to update the position // If the user is dragging the thumb, we don't want to update the position
@@ -169,7 +177,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
} }
void _onDragStart(DragStartDetails _) { void _onDragStart(DragStartDetails _) {
if (_monthCount >= kMinMonthsToEnableScrubberSnap) {
ref.read(timelineStateProvider.notifier).setScrubbing(true); ref.read(timelineStateProvider.notifier).setScrubbing(true);
}
setState(() { setState(() {
_isDragging = true; _isDragging = true;
_labelAnimationController.forward(); _labelAnimationController.forward();
@@ -191,13 +202,22 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
final nearestMonthSegment = _findNearestMonthSegment(dragPosition); final nearestMonthSegment = _findNearestMonthSegment(dragPosition);
if (nearestMonthSegment != null) { if (nearestMonthSegment != null) {
_snapToSegment(nearestMonthSegment);
final label = nearestMonthSegment.scrollLabel; final label = nearestMonthSegment.scrollLabel;
if (_lastLabel != label) { if (_lastLabel != label) {
ref.read(hapticFeedbackProvider.notifier).selectionClick(); ref.read(hapticFeedbackProvider.notifier).selectionClick();
_lastLabel = label; _lastLabel = label;
} }
} }
if (_monthCount < kMinMonthsToEnableScrubberSnap) {
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
setState(() {
_thumbTopOffset = dragPosition;
_scrollController.jumpTo((dragPosition / _scrubberHeight) * _scrollController.position.maxScrollExtent);
});
} else if (nearestMonthSegment != null) {
_snapToSegment(nearestMonthSegment);
}
} }
/// Calculate the drag position relative to the scrubber area /// Calculate the drag position relative to the scrubber area
+2 -2
View File
@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
@@ -13,6 +12,7 @@ import 'package:immich_mobile/services/etag.service.dart';
import 'package:immich_mobile/services/exif.service.dart'; import 'package:immich_mobile/services/exif.service.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final assetProvider = StateNotifierProvider<AssetNotifier, bool>((ref) { final assetProvider = StateNotifierProvider<AssetNotifier, bool>((ref) {
return AssetNotifier( return AssetNotifier(
@@ -68,7 +68,7 @@ class AssetNotifier extends StateNotifier<bool> {
} }
final bool newRemote = await _assetService.refreshRemoteAssets(); final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums(); final bool newLocal = await _albumService.refreshDeviceAlbums();
debugPrint("changedUsers: $changedUsers, newRemote: $newRemote, newLocal: $newLocal"); dPrint(() => "changedUsers: $changedUsers, newRemote: $newRemote, newLocal: $newLocal");
if (newRemote) { if (newRemote) {
_ref.invalidate(memoryFutureProvider); _ref.invalidate(memoryFutureProvider);
} }
+2 -5
View File
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_udid/flutter_udid.dart'; import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
@@ -18,6 +17,7 @@ import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/hash.dart'; import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) { final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier( return AuthNotifier(
@@ -150,10 +150,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
_log.severe("Error getting user information from the server [API EXCEPTION]", stackTrace); _log.severe("Error getting user information from the server [API EXCEPTION]", stackTrace);
} catch (error, stackTrace) { } catch (error, stackTrace) {
_log.severe("Error getting user information from the server [CATCH ALL]", error, stackTrace); _log.severe("Error getting user information from the server [CATCH ALL]", error, stackTrace);
dPrint(() => "Error getting user information from the server [CATCH ALL] $error $stackTrace");
if (kDebugMode) {
debugPrint("Error getting user information from the server [CATCH ALL] $error $stackTrace");
}
} }
// If the user is null, the login was not successful // If the user is null, the login was not successful
@@ -2,8 +2,6 @@ import 'dart:io';
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
@@ -33,6 +31,7 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
import 'package:immich_mobile/utils/debug_print.dart';
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) { final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier( return BackupNotifier(
@@ -286,7 +285,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(selectedBackupAlbums: selectedAlbums, excludedBackupAlbums: excludedAlbums); state = state.copyWith(selectedBackupAlbums: selectedAlbums, excludedBackupAlbums: excludedAlbums);
log.info("_getBackupAlbumsInfo: Found ${availableAlbums.length} available albums"); log.info("_getBackupAlbumsInfo: Found ${availableAlbums.length} available albums");
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms"); dPrint(() => "_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
} }
/// ///
@@ -428,7 +427,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Invoke backup process /// Invoke backup process
Future<void> startBackupProcess() async { Future<void> startBackupProcess() async {
debugPrint("Start backup process"); dPrint(() => "Start backup process");
assert(state.backupProgress == BackUpProgressEnum.idle); assert(state.backupProgress == BackUpProgressEnum.idle);
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress); state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
@@ -4,7 +4,6 @@ import 'dart:convert';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -14,6 +13,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class EnqueueStatus { class EnqueueStatus {
final int enqueueCount; final int enqueueCount;
@@ -329,16 +329,16 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
} }
Future<void> cancel() async { Future<void> cancel() async {
debugPrint("Canceling backup tasks..."); dPrint(() => "Canceling backup tasks...");
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true); state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true);
final activeTaskCount = await _uploadService.cancelBackup(); final activeTaskCount = await _uploadService.cancelBackup();
if (activeTaskCount > 0) { if (activeTaskCount > 0) {
debugPrint("$activeTaskCount tasks left, continuing to cancel..."); dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
await cancel(); await cancel();
} else { } else {
debugPrint("All tasks canceled successfully."); dPrint(() => "All tasks canceled successfully.");
// Clear all upload items when cancellation is complete // Clear all upload items when cancellation is complete
state = state.copyWith(isCanceling: false, uploadItems: {}); state = state.copyWith(isCanceling: false, uploadItems: {});
} }
@@ -30,6 +30,7 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
import 'package:immich_mobile/utils/debug_print.dart';
final manualUploadProvider = StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) { final manualUploadProvider = StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
return ManualUploadNotifier( return ManualUploadNotifier(
@@ -216,7 +217,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
); );
if (uploadAssets.isEmpty) { if (uploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process"); dPrint(() => "[_startUpload] No Assets to upload - Abort Process");
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
return false; return false;
} }
@@ -294,10 +295,10 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
} }
} else { } else {
openAppSettings(); openAppSettings();
debugPrint("[_startUpload] Do not have permission to the gallery"); dPrint(() => "[_startUpload] Do not have permission to the gallery");
} }
} catch (e) { } catch (e) {
debugPrint("ERROR _startUpload: ${e.toString()}"); dPrint(() => "ERROR _startUpload: ${e.toString()}");
hasErrors = true; hasErrors = true;
} finally { } finally {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
@@ -340,7 +341,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
// waits until it has stopped to start the backup. // waits until it has stopped to start the backup.
final bool hasLock = await ref.read(backgroundServiceProvider).acquireLock(); final bool hasLock = await ref.read(backgroundServiceProvider).acquireLock();
if (!hasLock) { if (!hasLock) {
debugPrint("[uploadAssets] could not acquire lock, exiting"); dPrint(() => "[uploadAssets] could not acquire lock, exiting");
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "failed".tr(), msg: "failed".tr(),
@@ -355,18 +356,18 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
// check if backup is already in process - then return // check if backup is already in process - then return
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) { if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
debugPrint("[uploadAssets] Manual upload is already running - abort"); dPrint(() => "[uploadAssets] Manual upload is already running - abort");
showInProgress = true; showInProgress = true;
} }
if (_backupProvider.backupProgress == BackUpProgressEnum.inProgress) { if (_backupProvider.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[uploadAssets] Auto Backup is already in progress - abort"); dPrint(() => "[uploadAssets] Auto Backup is already in progress - abort");
showInProgress = true; showInProgress = true;
return false; return false;
} }
if (_backupProvider.backupProgress == BackUpProgressEnum.inBackground) { if (_backupProvider.backupProgress == BackUpProgressEnum.inBackground) {
debugPrint("[uploadAssets] Background backup is running - abort"); dPrint(() => "[uploadAssets] Background backup is running - abort");
showInProgress = true; showInProgress = true;
} }
+4 -3
View File
@@ -7,11 +7,12 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) { final immichThemeModeProvider = StateProvider<ThemeMode>((ref) {
final themeMode = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.themeMode); final themeMode = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.themeMode);
debugPrint("Current themeMode $themeMode"); dPrint(() => "Current themeMode $themeMode");
if (themeMode == ThemeMode.light.name) { if (themeMode == ThemeMode.light.name) {
return ThemeMode.light; return ThemeMode.light;
@@ -26,12 +27,12 @@ final immichThemePresetProvider = StateProvider<ImmichColorPreset>((ref) {
final appSettingsProvider = ref.watch(appSettingsServiceProvider); final appSettingsProvider = ref.watch(appSettingsServiceProvider);
final primaryColorPreset = appSettingsProvider.getSetting(AppSettingsEnum.primaryColor); final primaryColorPreset = appSettingsProvider.getSetting(AppSettingsEnum.primaryColor);
debugPrint("Current theme preset $primaryColorPreset"); dPrint(() => "Current theme preset $primaryColorPreset");
try { try {
return ImmichColorPreset.values.firstWhere((e) => e.name == primaryColorPreset); return ImmichColorPreset.values.firstWhere((e) => e.name == primaryColorPreset);
} catch (e) { } catch (e) {
debugPrint("Theme preset $primaryColorPreset not found. Applying default preset."); dPrint(() => "Theme preset $primaryColorPreset not found. Applying default preset.");
appSettingsProvider.setSetting(AppSettingsEnum.primaryColor, defaultColorPresetName); appSettingsProvider.setSetting(AppSettingsEnum.primaryColor, defaultColorPresetName);
return defaultColorPreset; return defaultColorPreset;
} }
@@ -1,10 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/utils/debug_print.dart';
enum UploadProfileStatus { idle, loading, success, failure } enum UploadProfileStatus { idle, loading, success, failure }
@@ -67,7 +67,7 @@ class UploadProfileImageNotifier extends StateNotifier<UploadProfileImageState>
var profileImagePath = await _userService.createProfileImage(file.name, await file.readAsBytes()); var profileImagePath = await _userService.createProfileImage(file.name, await file.readAsBytes());
if (profileImagePath != null) { if (profileImagePath != null) {
debugPrint("Successfully upload profile image"); dPrint(() => "Successfully upload profile image");
state = state.copyWith(status: UploadProfileStatus.success, profileImagePath: profileImagePath); state = state.copyWith(status: UploadProfileStatus.success, profileImagePath: profileImagePath);
return true; return true;
} }
+11 -9
View File
@@ -2,8 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
@@ -20,6 +18,7 @@ import 'package:immich_mobile/utils/debounce.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:socket_io_client/socket_io_client.dart'; import 'package:socket_io_client/socket_io_client.dart';
import 'package:immich_mobile/utils/debug_print.dart';
enum PendingAction { assetDelete, assetUploaded, assetHidden, assetTrash } enum PendingAction { assetDelete, assetUploaded, assetHidden, assetTrash }
@@ -105,7 +104,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}"; headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
} }
debugPrint("Attempting to connect to websocket"); dPrint(() => "Attempting to connect to websocket");
// Configure socket transports must be specified // Configure socket transports must be specified
Socket socket = io( Socket socket = io(
endpoint.origin, endpoint.origin,
@@ -121,12 +120,12 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
); );
socket.onConnect((_) { socket.onConnect((_) {
debugPrint("Established Websocket Connection"); dPrint(() => "Established Websocket Connection");
state = WebsocketState(isConnected: true, socket: socket, pendingChanges: state.pendingChanges); state = WebsocketState(isConnected: true, socket: socket, pendingChanges: state.pendingChanges);
}); });
socket.onDisconnect((_) { socket.onDisconnect((_) {
debugPrint("Disconnect to Websocket Connection"); dPrint(() => "Disconnect to Websocket Connection");
state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges); state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges);
}); });
@@ -150,13 +149,13 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates); socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) { } catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
} }
} }
} }
void disconnect() { void disconnect() {
debugPrint("Attempting to disconnect from websocket"); dPrint(() => "Attempting to disconnect from websocket");
_batchedAssetUploadReady.clear(); _batchedAssetUploadReady.clear();
@@ -200,7 +199,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
} }
void listenUploadEvent() { void listenUploadEvent() {
debugPrint("Start listening to event on_upload_success"); dPrint(() => "Start listening to event on_upload_success");
state.socket?.on('on_upload_success', _handleOnUploadSuccess); state.socket?.on('on_upload_success', _handleOnUploadSuccess);
} }
@@ -321,10 +320,13 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
return; return;
} }
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
try { try {
unawaited( unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) { _ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) {
return _ref.read(backgroundSyncProvider).syncLinkedAlbum(); if (isSyncAlbumEnabled) {
_ref.read(backgroundSyncProvider).syncLinkedAlbum();
}
}), }),
); );
} catch (error) { } catch (error) {
@@ -3,12 +3,12 @@ import 'dart:io';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class UploadTaskWithFile { class UploadTaskWithFile {
final File file; final File file;
@@ -79,14 +79,17 @@ class UploadRepository {
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup), FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
]); ]);
debugPrint(""" dPrint(
() =>
"""
Upload Info: Upload Info:
Enqueued: ${enqueuedTasks.length} Enqueued: ${enqueuedTasks.length}
Running: ${runningTasks.length} Running: ${runningTasks.length}
Canceled: ${canceledTasks.length} Canceled: ${canceledTasks.length}
Waiting: ${waitingTasks.length} Waiting: ${waitingTasks.length}
Paused: ${pausedTasks.length} Paused: ${pausedTasks.length}
"""); """,
);
} }
Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, CancellationToken cancelToken) async { Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, CancellationToken cancelToken) async {
+2 -2
View File
@@ -1,5 +1,5 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart'; import 'package:immich_mobile/utils/debug_print.dart';
/// Guards against duplicate navigation to this route /// Guards against duplicate navigation to this route
class DuplicateGuard extends AutoRouteGuard { class DuplicateGuard extends AutoRouteGuard {
@@ -8,7 +8,7 @@ class DuplicateGuard extends AutoRouteGuard {
void onNavigation(NavigationResolver resolver, StackRouter router) async { void onNavigation(NavigationResolver resolver, StackRouter router) async {
// Duplicate navigation // Duplicate navigation
if (resolver.route.name == router.current.name) { if (resolver.route.name == router.current.name) {
debugPrint('DuplicateGuard: Preventing duplicate route navigation for ${resolver.route.name}'); dPrint(() => 'DuplicateGuard: Preventing duplicate route navigation for ${resolver.route.name}');
resolver.next(false); resolver.next(false);
} else { } else {
resolver.next(true); resolver.next(true);
+12 -12
View File
@@ -3,7 +3,6 @@ import 'dart:collection';
import 'dart:io'; import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
@@ -24,6 +23,7 @@ import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/utils/hash.dart'; import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final albumServiceProvider = Provider( final albumServiceProvider = Provider(
(ref) => AlbumService( (ref) => AlbumService(
@@ -124,7 +124,7 @@ class AlbumService {
} finally { } finally {
_localCompleter.complete(changes); _localCompleter.complete(changes);
} }
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms"); dPrint(() => "refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
return changes; return changes;
} }
@@ -172,7 +172,7 @@ class AlbumService {
} finally { } finally {
_remoteCompleter.complete(changes); _remoteCompleter.complete(changes);
} }
debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms"); dPrint(() => "refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms");
return changes; return changes;
} }
@@ -220,7 +220,7 @@ class AlbumService {
return AlbumAddAssetsResponse(alreadyInAlbum: result.duplicates, successfullyAdded: addedAssets.length); return AlbumAddAssetsResponse(alreadyInAlbum: result.duplicates, successfullyAdded: addedAssets.length);
} catch (e) { } catch (e) {
debugPrint("Error addAssets ${e.toString()}"); dPrint(() => "Error addAssets ${e.toString()}");
} }
return null; return null;
} }
@@ -242,7 +242,7 @@ class AlbumService {
await _albumRepository.update(album); await _albumRepository.update(album);
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error setActivityEnabled ${e.toString()}"); dPrint(() => "Error setActivityEnabled ${e.toString()}");
} }
return false; return false;
} }
@@ -271,7 +271,7 @@ class AlbumService {
} }
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}"); dPrint(() => "Error deleteAlbum ${e.toString()}");
} }
return false; return false;
} }
@@ -281,7 +281,7 @@ class AlbumService {
await _albumApiRepository.removeUser(album.remoteId!, userId: "me"); await _albumApiRepository.removeUser(album.remoteId!, userId: "me");
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error leaveAlbum ${e.toString()}"); dPrint(() => "Error leaveAlbum ${e.toString()}");
return false; return false;
} }
} }
@@ -293,7 +293,7 @@ class AlbumService {
await _updateAssets(album.id, remove: toRemove.toList()); await _updateAssets(album.id, remove: toRemove.toList());
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error removeAssetFromAlbum ${e.toString()}"); dPrint(() => "Error removeAssetFromAlbum ${e.toString()}");
} }
return false; return false;
} }
@@ -310,7 +310,7 @@ class AlbumService {
return true; return true;
} catch (error) { } catch (error) {
debugPrint("Error removeUser ${error.toString()}"); dPrint(() => "Error removeUser ${error.toString()}");
return false; return false;
} }
} }
@@ -327,7 +327,7 @@ class AlbumService {
return true; return true;
} catch (error) { } catch (error) {
debugPrint("Error addUsers ${error.toString()}"); dPrint(() => "Error addUsers ${error.toString()}");
} }
return false; return false;
} }
@@ -340,7 +340,7 @@ class AlbumService {
await _albumRepository.update(album); await _albumRepository.update(album);
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error changeTitleAlbum ${e.toString()}"); dPrint(() => "Error changeTitleAlbum ${e.toString()}");
return false; return false;
} }
} }
@@ -353,7 +353,7 @@ class AlbumService {
await _albumRepository.update(album); await _albumRepository.update(album);
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error changeDescriptionAlbum ${e.toString()}"); dPrint(() => "Error changeDescriptionAlbum ${e.toString()}");
return false; return false;
} }
} }
+2 -2
View File
@@ -3,7 +3,6 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@@ -11,6 +10,7 @@ import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/user_agent.dart'; import 'package:immich_mobile/utils/user_agent.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class ApiService implements Authentication { class ApiService implements Authentication {
late ApiClient _apiClient; late ApiClient _apiClient;
@@ -155,7 +155,7 @@ class ApiService implements Authentication {
return endpoint; return endpoint;
} }
} catch (e) { } catch (e) {
debugPrint("Could not locate /.well-known/immich at $baseUrl"); dPrint(() => "Could not locate /.well-known/immich at $baseUrl");
} }
return ""; return "";
+3 -3
View File
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
@@ -26,6 +25,7 @@ import 'package:immich_mobile/services/sync.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final assetServiceProvider = Provider( final assetServiceProvider = Provider(
(ref) => AssetService( (ref) => AssetService(
@@ -87,7 +87,7 @@ class AssetService {
getChangedAssets: _getRemoteAssetChanges, getChangedAssets: _getRemoteAssetChanges,
loadAssets: _getRemoteAssets, loadAssets: _getRemoteAssets,
); );
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms"); dPrint(() => "refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
return changes; return changes;
} }
@@ -156,7 +156,7 @@ class AssetService {
if (a.isInDb) { if (a.isInDb) {
await _assetRepository.transaction(() => _assetRepository.update(a)); await _assetRepository.transaction(() => _assetRepository.update(a));
} else { } else {
debugPrint("[loadExif] parameter Asset is not from DB!"); dPrint(() => "[loadExif] parameter Asset is not from DB!");
} }
} }
} }
+11 -13
View File
@@ -7,7 +7,6 @@ import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -29,6 +28,7 @@ import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart';
@@ -165,7 +165,7 @@ class BackgroundService {
]); ]);
} }
} catch (error) { } catch (error) {
debugPrint("[_updateNotification] failed to communicate with plugin"); dPrint(() => "[_updateNotification] failed to communicate with plugin");
} }
return false; return false;
} }
@@ -177,7 +177,7 @@ class BackgroundService {
return await _backgroundChannel.invokeMethod('showError', [title, content, individualTag]); return await _backgroundChannel.invokeMethod('showError', [title, content, individualTag]);
} }
} catch (error) { } catch (error) {
debugPrint("[_showErrorNotification] failed to communicate with plugin"); dPrint(() => "[_showErrorNotification] failed to communicate with plugin");
} }
return false; return false;
} }
@@ -188,7 +188,7 @@ class BackgroundService {
return await _backgroundChannel.invokeMethod('clearErrorNotifications'); return await _backgroundChannel.invokeMethod('clearErrorNotifications');
} }
} catch (error) { } catch (error) {
debugPrint("[_clearErrorNotifications] failed to communicate with plugin"); dPrint(() => "[_clearErrorNotifications] failed to communicate with plugin");
} }
return false; return false;
} }
@@ -196,7 +196,7 @@ class BackgroundService {
/// await to ensure this thread (foreground or background) has exclusive access /// await to ensure this thread (foreground or background) has exclusive access
Future<bool> acquireLock() async { Future<bool> acquireLock() async {
if (_hasLock) { if (_hasLock) {
debugPrint("WARNING: [acquireLock] called more than once"); dPrint(() => "WARNING: [acquireLock] called more than once");
return true; return true;
} }
final int lockTime = Timeline.now; final int lockTime = Timeline.now;
@@ -302,19 +302,19 @@ class BackgroundService {
final bool hasAccess = await waitForLock; final bool hasAccess = await waitForLock;
if (!hasAccess) { if (!hasAccess) {
debugPrint("[_callHandler] could not acquire lock, exiting"); dPrint(() => "[_callHandler] could not acquire lock, exiting");
return false; return false;
} }
final translationsOk = await loadTranslations(); final translationsOk = await loadTranslations();
if (!translationsOk) { if (!translationsOk) {
debugPrint("[_callHandler] could not load translations"); dPrint(() => "[_callHandler] could not load translations");
} }
final bool ok = await _onAssetsChanged(); final bool ok = await _onAssetsChanged();
return ok; return ok;
} catch (error) { } catch (error) {
debugPrint(error.toString()); dPrint(() => error.toString());
return false; return false;
} finally { } finally {
releaseLock(); releaseLock();
@@ -324,14 +324,14 @@ class BackgroundService {
_cancellationToken?.cancel(); _cancellationToken?.cancel();
return true; return true;
default: default:
debugPrint("Unknown method ${call.method}"); dPrint(() => "Unknown method ${call.method}");
return false; return false;
} }
} }
Future<bool> _onAssetsChanged() async { Future<bool> _onAssetsChanged() async {
final (isar, drift, logDb) = await Bootstrap.initDB(); final (isar, drift, logDb) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDb); await Bootstrap.initDomain(isar, drift, logDb, shouldBufferLogs: false, listenStoreUpdates: false);
final ref = ProviderContainer( final ref = ProviderContainer(
overrides: [ overrides: [
@@ -344,9 +344,7 @@ class BackgroundService {
HttpSSLOptions.apply(); HttpSSLOptions.apply();
ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken));
await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
if (kDebugMode) { dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
debugPrint("[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
}
final selectedAlbums = await ref.read(backupAlbumRepositoryProvider).getAllBySelection(BackupSelection.select); final selectedAlbums = await ref.read(backupAlbumRepositoryProvider).getAllBySelection(BackupSelection.select);
final excludedAlbums = await ref.read(backupAlbumRepositoryProvider).getAllBySelection(BackupSelection.exclude); final excludedAlbums = await ref.read(backupAlbumRepositoryProvider).getAllBySelection(BackupSelection.exclude);
+10 -7
View File
@@ -4,7 +4,6 @@ import 'dart:io';
import 'package:cancellation_token_http/http.dart' as http; import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
@@ -29,6 +28,7 @@ import 'package:openapi/api.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:permission_handler/permission_handler.dart' as pm; import 'package:permission_handler/permission_handler.dart' as pm;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
import 'package:immich_mobile/utils/debug_print.dart';
final backupServiceProvider = Provider( final backupServiceProvider = Provider(
(ref) => BackupService( (ref) => BackupService(
@@ -69,7 +69,7 @@ class BackupService {
try { try {
return await _apiService.assetsApi.getAllUserAssetsByDeviceId(deviceId); return await _apiService.assetsApi.getAllUserAssetsByDeviceId(deviceId);
} catch (e) { } catch (e) {
debugPrint('Error [getDeviceBackupAsset] ${e.toString()}'); dPrint(() => 'Error [getDeviceBackupAsset] ${e.toString()}');
return null; return null;
} }
} }
@@ -356,7 +356,8 @@ class BackupService {
final error = responseBody; final error = responseBody;
final errorMessage = error['message'] ?? error['error']; final errorMessage = error['message'] ?? error['error'];
debugPrint( dPrint(
() =>
"Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}", "Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}",
); );
@@ -398,11 +399,11 @@ class BackupService {
} }
} }
} on http.CancelledException { } on http.CancelledException {
debugPrint("Backup was cancelled by the user"); dPrint(() => "Backup was cancelled by the user");
anyErrors = true; anyErrors = true;
break; break;
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrint("Error backup asset: ${error.toString()}: $stackTrace"); dPrint(() => "Error backup asset: ${error.toString()}: $stackTrace");
anyErrors = true; anyErrors = true;
continue; continue;
} finally { } finally {
@@ -411,7 +412,7 @@ class BackupService {
await file?.delete(); await file?.delete();
await livePhotoFile?.delete(); await livePhotoFile?.delete();
} catch (e) { } catch (e) {
debugPrint("ERROR deleting file: ${e.toString()}"); dPrint(() => "ERROR deleting file: ${e.toString()}");
} }
} }
} }
@@ -454,7 +455,9 @@ class BackupService {
if (![200, 201].contains(response.statusCode)) { if (![200, 201].contains(response.statusCode)) {
var error = responseBody; var error = responseBody;
debugPrint("Error(${error['statusCode']}) uploading livePhoto for assetId | $livePhotoTitle | ${error['error']}"); dPrint(
() => "Error(${error['statusCode']}) uploading livePhoto for assetId | $livePhotoTitle | ${error['error']}",
);
} }
return responseBody.containsKey('id') ? responseBody['id'] : null; return responseBody.containsKey('id') ? responseBody['id'] : null;
@@ -1,9 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final localNotificationService = Provider( final localNotificationService = Provider(
(ref) => LocalNotificationService(ref.watch(notificationPermissionProvider), ref), (ref) => LocalNotificationService(ref.watch(notificationPermissionProvider), ref),
@@ -110,7 +110,7 @@ class LocalNotificationService {
switch (notificationResponse.actionId) { switch (notificationResponse.actionId) {
case cancelUploadActionID: case cancelUploadActionID:
{ {
debugPrint("User cancelled manual upload operation"); dPrint(() => "User cancelled manual upload operation");
ref.read(manualUploadProvider.notifier).cancelBackup(); ref.read(manualUploadProvider.notifier).cancelBackup();
} }
} }
@@ -2,9 +2,9 @@
import 'package:easy_localization/src/easy_localization_controller.dart'; import 'package:easy_localization/src/easy_localization_controller.dart';
import 'package:easy_localization/src/localization.dart'; import 'package:easy_localization/src/localization.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/utils/debug_print.dart';
/// Workaround to manually load translations in another Isolate /// Workaround to manually load translations in another Isolate
Future<bool> loadTranslations() async { Future<bool> loadTranslations() async {
@@ -17,7 +17,7 @@ Future<bool> loadTranslations() async {
assetLoader: const CodegenLoader(), assetLoader: const CodegenLoader(),
path: translationsPath, path: translationsPath,
useOnlyLangCode: false, useOnlyLangCode: false,
onLoadError: (e) => debugPrint(e.toString()), onLoadError: (e) => dPrint(() => e.toString()),
fallbackLocale: locales.values.first, fallbackLocale: locales.values.first,
); );
+2 -2
View File
@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
@@ -10,6 +9,7 @@ import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final searchServiceProvider = Provider( final searchServiceProvider = Provider(
(ref) => SearchService( (ref) => SearchService(
@@ -43,7 +43,7 @@ class SearchService {
model: model, model: model,
); );
} catch (e) { } catch (e) {
debugPrint("[ERROR] [getSearchSuggestions] ${e.toString()}"); dPrint(() => "[ERROR] [getSearchSuggestions] ${e.toString()}");
return []; return [];
} }
} }
+5 -5
View File
@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/server_info/server_config.model.dart'; import 'package:immich_mobile/models/server_info/server_config.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
@@ -6,6 +5,7 @@ import 'package:immich_mobile/models/server_info/server_features.model.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final serverInfoServiceProvider = Provider((ref) => ServerInfoService(ref.watch(apiServiceProvider))); final serverInfoServiceProvider = Provider((ref) => ServerInfoService(ref.watch(apiServiceProvider)));
@@ -30,7 +30,7 @@ class ServerInfoService {
return ServerDiskInfo.fromDto(dto); return ServerDiskInfo.fromDto(dto);
} }
} catch (e) { } catch (e) {
debugPrint("Error [getDiskInfo] ${e.toString()}"); dPrint(() => "Error [getDiskInfo] ${e.toString()}");
} }
return null; return null;
} }
@@ -42,7 +42,7 @@ class ServerInfoService {
return ServerVersion.fromDto(dto); return ServerVersion.fromDto(dto);
} }
} catch (e) { } catch (e) {
debugPrint("Error [getServerVersion] ${e.toString()}"); dPrint(() => "Error [getServerVersion] ${e.toString()}");
} }
return null; return null;
} }
@@ -54,7 +54,7 @@ class ServerInfoService {
return ServerFeatures.fromDto(dto); return ServerFeatures.fromDto(dto);
} }
} catch (e) { } catch (e) {
debugPrint("Error [getServerFeatures] ${e.toString()}"); dPrint(() => "Error [getServerFeatures] ${e.toString()}");
} }
return null; return null;
} }
@@ -66,7 +66,7 @@ class ServerInfoService {
return ServerConfig.fromDto(dto); return ServerConfig.fromDto(dto);
} }
} catch (e) { } catch (e) {
debugPrint("Error [getServerConfig] ${e.toString()}"); dPrint(() => "Error [getServerConfig] ${e.toString()}");
} }
return null; return null;
} }
+5 -5
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class StackService { class StackService {
const StackService(this._api, this._assetRepository); const StackService(this._api, this._assetRepository);
@@ -16,7 +16,7 @@ class StackService {
try { try {
return _api.stacksApi.getStack(stackId); return _api.stacksApi.getStack(stackId);
} catch (error) { } catch (error) {
debugPrint("Error while fetching stack: $error"); dPrint(() => "Error while fetching stack: $error");
} }
return null; return null;
} }
@@ -25,7 +25,7 @@ class StackService {
try { try {
return _api.stacksApi.createStack(StackCreateDto(assetIds: assetIds)); return _api.stacksApi.createStack(StackCreateDto(assetIds: assetIds));
} catch (error) { } catch (error) {
debugPrint("Error while creating stack: $error"); dPrint(() => "Error while creating stack: $error");
} }
return null; return null;
} }
@@ -34,7 +34,7 @@ class StackService {
try { try {
return await _api.stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId)); return await _api.stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId));
} catch (error) { } catch (error) {
debugPrint("Error while updating stack children: $error"); dPrint(() => "Error while updating stack children: $error");
} }
return null; return null;
} }
@@ -54,7 +54,7 @@ class StackService {
} }
await _assetRepository.transaction(() => _assetRepository.updateAll(removeAssets)); await _assetRepository.transaction(() => _assetRepository.updateAll(removeAssets));
} catch (error) { } catch (error) {
debugPrint("Error while deleting stack: $error"); dPrint(() => "Error while deleting stack: $error");
} }
} }
} }
+2 -2
View File
@@ -4,7 +4,6 @@ import 'dart:io';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -22,6 +21,7 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:immich_mobile/utils/debug_print.dart';
final uploadServiceProvider = Provider((ref) { final uploadServiceProvider = Provider((ref) {
final service = UploadService( final service = UploadService(
@@ -253,7 +253,7 @@ class UploadService {
enqueueTasks([uploadTask]); enqueueTasks([uploadTask]);
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrint("Error handling live photo upload task: $error $stackTrace"); dPrint(() => "Error handling live photo upload task: $error $stackTrace");
} }
} }
+3 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/debug_print.dart';
abstract final class DynamicTheme { abstract final class DynamicTheme {
const DynamicTheme._(); const DynamicTheme._();
@@ -13,7 +14,7 @@ abstract final class DynamicTheme {
final corePalette = await DynamicColorPlugin.getCorePalette(); final corePalette = await DynamicColorPlugin.getCorePalette();
if (corePalette != null) { if (corePalette != null) {
final primaryColor = corePalette.toColorScheme().primary; final primaryColor = corePalette.toColorScheme().primary;
debugPrint('dynamic_color: Core palette detected.'); dPrint(() => 'dynamic_color: Core palette detected.');
// Some palettes do not generate surface container colors accurately, // Some palettes do not generate surface container colors accurately,
// so we regenerate all colors using the primary color // so we regenerate all colors using the primary color
@@ -23,7 +24,7 @@ abstract final class DynamicTheme {
); );
} }
} catch (error) { } catch (error) {
debugPrint('dynamic_color: Failed to obtain core palette: $error'); dPrint(() => 'dynamic_color: Failed to obtain core palette: $error');
} }
} }
+8 -2
View File
@@ -89,11 +89,17 @@ abstract final class Bootstrap {
return (isar, drift, logDb); return (isar, drift, logDb);
} }
static Future<void> initDomain(Isar db, Drift drift, DriftLogger logDb, {bool shouldBufferLogs = true}) async { static Future<void> initDomain(
Isar db,
Drift drift,
DriftLogger logDb, {
bool listenStoreUpdates = true,
bool shouldBufferLogs = true,
}) async {
final isBeta = await IsarStoreRepository(db).tryGet(StoreKey.betaTimeline) ?? true; final isBeta = await IsarStoreRepository(db).tryGet(StoreKey.betaTimeline) ?? true;
final IStoreRepository storeRepo = isBeta ? DriftStoreRepository(drift) : IsarStoreRepository(db); final IStoreRepository storeRepo = isBeta ? DriftStoreRepository(drift) : IsarStoreRepository(db);
await StoreService.init(storeRepository: storeRepo); await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
await LogService.init( await LogService.init(
logRepository: LogRepository(logDb), logRepository: LogRepository(logDb),
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter/foundation.dart';
@pragma('vm:prefer-inline')
void dPrint(String Function() message) {
if (kDebugMode) {
debugPrint(message());
}
}
+7 -5
View File
@@ -1,14 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart'; import 'package:worker_manager/worker_manager.dart';
@@ -37,7 +38,7 @@ Cancelable<T?> runInIsolateGentle<T>({
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
final (isar, drift, logDb) = await Bootstrap.initDB(); final (isar, drift, logDb) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDb, shouldBufferLogs: false); await Bootstrap.initDomain(isar, drift, logDb, shouldBufferLogs: false, listenStoreUpdates: false);
final ref = ProviderContainer( final ref = ProviderContainer(
overrides: [ overrides: [
// TODO: Remove once isar is removed // TODO: Remove once isar is removed
@@ -61,6 +62,7 @@ Cancelable<T?> runInIsolateGentle<T>({
try { try {
ref.dispose(); ref.dispose();
await Store.dispose();
await LogService.I.dispose(); await LogService.I.dispose();
await logDb.close(); await logDb.close();
await drift.close(); await drift.close();
@@ -71,10 +73,10 @@ Cancelable<T?> runInIsolateGentle<T>({
await isar.close(); await isar.close();
} }
} catch (e) { } catch (e) {
debugPrint("Error closing Isar: $e"); dPrint(() => "Error closing Isar: $e");
} }
} catch (error, stack) { } catch (error, stack) {
debugPrint("Error closing resources in isolate: $error, $stack"); dPrint(() => "Error closing resources in isolate: $error, $stack");
} finally { } finally {
ref.dispose(); ref.dispose();
// Delay to ensure all resources are released // Delay to ensure all resources are released
@@ -84,7 +86,7 @@ Cancelable<T?> runInIsolateGentle<T>({
return null; return null;
}, },
(error, stack) { (error, stack) {
debugPrint("Error in isolate zone: $error, $stack"); dPrint(() => "Error in isolate $debugLabel zone: $error, $stack");
}, },
); );
return null; return null;
+20 -47
View File
@@ -4,7 +4,6 @@ import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
@@ -22,12 +21,14 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager // ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 15; const int targetVersion = 16;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async { Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null; final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -76,11 +77,16 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await Store.put(StoreKey.needBetaMigration, false); await Store.put(StoreKey.needBetaMigration, false);
await Store.put(StoreKey.betaTimeline, true); await Store.put(StoreKey.betaTimeline, true);
} else { } else {
await resetDriftDatabase(drift); await drift.reset();
await Store.put(StoreKey.needBetaMigration, true); await Store.put(StoreKey.needBetaMigration, true);
} }
} }
if (version < 16) {
await SyncStreamRepository(drift).reset();
await Store.put(StoreKey.shouldResetSync, true);
}
if (targetVersion >= 12) { if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion); await Store.put(StoreKey.version, targetVersion);
return; return;
@@ -117,7 +123,7 @@ Future<bool> _isNewInstallation(Isar db, Drift drift) async {
return true; return true;
} catch (error) { } catch (error) {
debugPrint("[MIGRATION] Error checking if new installation: $error"); dPrint(() => "[MIGRATION] Error checking if new installation: $error");
return false; return false;
} }
} }
@@ -143,10 +149,7 @@ Future<void> _migrateDeviceAsset(Isar db) async {
final PermissionState ps = await PhotoManager.requestPermissionExtend(); final PermissionState ps = await PhotoManager.requestPermissionExtend();
if (!ps.hasAccess) { if (!ps.hasAccess) {
if (kDebugMode) { dPrint(() => "[MIGRATION] Photo library permission not granted. Skipping device asset migration.");
debugPrint("[MIGRATION] Photo library permission not granted. Skipping device asset migration.");
}
return; return;
} }
@@ -166,8 +169,8 @@ Future<void> _migrateDeviceAsset(Isar db) async {
localAssets = allDeviceAssets.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime)).toList(); localAssets = allDeviceAssets.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime)).toList();
} }
debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}"); dPrint(() => "[MIGRATION] Device Asset Ids length - ${ids.length}");
debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}"); dPrint(() => "[MIGRATION] Local Asset Ids length - ${localAssets.length}");
ids.sort((a, b) => a.assetId.compareTo(b.assetId)); ids.sort((a, b) => a.assetId.compareTo(b.assetId));
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId)); localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
final List<DeviceAssetEntity> toAdd = []; final List<DeviceAssetEntity> toAdd = [];
@@ -182,20 +185,14 @@ Future<void> _migrateDeviceAsset(Isar db) async {
return false; return false;
}, },
onlyFirst: (deviceAsset) { onlyFirst: (deviceAsset) {
if (kDebugMode) { dPrint(() => '[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}');
debugPrint('[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}');
}
}, },
onlySecond: (asset) { onlySecond: (asset) {
if (kDebugMode) { dPrint(() => '[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}');
debugPrint('[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}');
}
}, },
); );
if (kDebugMode) { dPrint(() => "[MIGRATION] Total number of device assets migrated - ${toAdd.length}");
debugPrint("[MIGRATION] Total number of device assets migrated - ${toAdd.length}");
}
await db.writeTxn(() async { await db.writeTxn(() async {
await db.deviceAssetEntitys.putAll(toAdd); await db.deviceAssetEntitys.putAll(toAdd);
@@ -215,7 +212,7 @@ Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
} }
}); });
} catch (error) { } catch (error) {
debugPrint("[MIGRATION] Error while migrating device assets to SQLite: $error"); dPrint(() => "[MIGRATION] Error while migrating device assets to SQLite: $error");
} }
} }
@@ -263,7 +260,7 @@ Future<void> migrateBackupAlbumsToSqlite(Isar db, Drift drift) async {
} }
}); });
} catch (error) { } catch (error) {
debugPrint("[MIGRATION] Error while migrating backup albums to SQLite: $error"); dPrint(() => "[MIGRATION] Error while migrating backup albums to SQLite: $error");
} }
} }
@@ -281,7 +278,7 @@ Future<void> migrateStoreToSqlite(Isar db, Drift drift) async {
} }
}); });
} catch (error) { } catch (error) {
debugPrint("[MIGRATION] Error while migrating store values to SQLite: $error"); dPrint(() => "[MIGRATION] Error while migrating store values to SQLite: $error");
} }
} }
@@ -296,7 +293,7 @@ Future<void> migrateStoreToIsar(Isar db, Drift drift) async {
await db.storeValues.putAll(driftStoreValues); await db.storeValues.putAll(driftStoreValues);
}); });
} catch (error) { } catch (error) {
debugPrint("[MIGRATION] Error while migrating store values to Isar: $error"); dPrint(() => "[MIGRATION] Error while migrating store values to Isar: $error");
} }
} }
@@ -307,27 +304,3 @@ class _DeviceAsset {
const _DeviceAsset({required this.assetId, this.hash, this.dateTime}); const _DeviceAsset({required this.assetId, this.hash, this.dateTime});
} }
Future<void> resetDriftDatabase(Drift drift) async {
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
final database = drift.attachedDatabase;
await database.exclusively(() async {
// https://stackoverflow.com/a/65743498/25690041
await database.customStatement('PRAGMA writable_schema = 1;');
await database.customStatement('DELETE FROM sqlite_master;');
await database.customStatement('VACUUM;');
await database.customStatement('PRAGMA writable_schema = 0;');
await database.customStatement('PRAGMA integrity_check');
await database.customStatement('PRAGMA user_version = 0');
await database.beforeOpen(
// ignore: invalid_use_of_internal_member
database.resolvedEngine.executor,
OpeningDetails(null, database.schemaVersion),
);
await database.customStatement('PRAGMA user_version = ${database.schemaVersion}');
// Refresh all stream queries
database.notifyUpdates({for (final table in database.allTables) TableUpdate.onTable(table)});
});
}
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class ExifMap extends StatelessWidget { class ExifMap extends StatelessWidget {
final ExifInfo exifInfo; final ExifInfo exifInfo;
@@ -66,7 +67,7 @@ class ExifMap extends StatelessWidget {
return; return;
} }
debugPrint('Opening Map Uri: $uri'); dPrint(() => 'Opening Map Uri: $uri');
launchUrl(uri); launchUrl(uri);
}, },
onCreated: onMapCreated, onCreated: onMapCreated,
@@ -14,6 +14,7 @@ import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
@@ -91,7 +92,7 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_prefer_remote_title".tr(), title: "advanced_settings_prefer_remote_title".tr(),
subtitle: "advanced_settings_prefer_remote_subtitle".tr(), subtitle: "advanced_settings_prefer_remote_subtitle".tr(),
), ),
const LocalStorageSettings(), if (!Store.isBetaTimelineEnabled) const LocalStorageSettings(),
SettingsSwitchListTile( SettingsSwitchListTile(
enabled: !isLoggedIn, enabled: !isLoggedIn,
valueNotifier: allowSelfSignedSSLCert, valueNotifier: allowSelfSignedSSLCert,
@@ -101,12 +102,13 @@ class AdvancedSettings extends HookConsumerWidget {
), ),
const CustomeProxyHeaderSettings(), const CustomeProxyHeaderSettings(),
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null),
if (!Store.isBetaTimelineEnabled)
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useAlternatePMFilter, valueNotifier: useAlternatePMFilter,
title: "advanced_settings_enable_alternate_media_filter_title".tr(), title: "advanced_settings_enable_alternate_media_filter_title".tr(),
subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(), subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(),
), ),
// TODO: Remove this check when beta timeline goes stable const BetaTimelineListTile(),
if (Store.isBetaTimelineEnabled) if (Store.isBetaTimelineEnabled)
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: readonlyModeEnabled, valueNotifier: readonlyModeEnabled,
@@ -5,13 +5,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -83,7 +82,7 @@ class SyncStatusAndActions extends HookConsumerWidget {
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await resetDriftDatabase(ref.read(driftProvider)); await ref.read(driftProvider).reset();
context.pop(); context.pop();
context.scaffoldMessenger.showSnackBar( context.scaffoldMessenger.showSnackBar(
SnackBar(content: Text("reset_sqlite_success".t(context: context))), SnackBar(content: Text("reset_sqlite_success".t(context: context))),
@@ -1,5 +1,3 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -12,50 +10,11 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
class BetaTimelineListTile extends ConsumerStatefulWidget { class BetaTimelineListTile extends ConsumerWidget {
const BetaTimelineListTile({super.key}); const BetaTimelineListTile({super.key});
@override @override
ConsumerState<BetaTimelineListTile> createState() => _BetaTimelineListTileState(); Widget build(BuildContext context, WidgetRef ref) {
}
class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile> with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _rotationAnimation;
late Animation<double> _pulseAnimation;
late Animation<double> _gradientAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(duration: const Duration(seconds: 3), vsync: this);
_rotationAnimation = Tween<double>(
begin: 0,
end: 2 * math.pi,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.linear));
_pulseAnimation = Tween<double>(
begin: 1,
end: 1.1,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
_gradientAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
_animationController.repeat(reverse: true);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final betaTimelineValue = ref.watch(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.betaTimeline); final betaTimelineValue = ref.watch(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.betaTimeline);
final serverInfo = ref.watch(serverInfoProvider); final serverInfo = ref.watch(serverInfoProvider);
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
@@ -64,9 +23,6 @@ class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile> wit
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
void onSwitchChanged(bool value) { void onSwitchChanged(bool value) {
showDialog( showDialog(
context: context, context: context,
@@ -99,133 +55,18 @@ class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile> wit
); );
} }
final gradientColors = [ return Padding(
Color.lerp( padding: const EdgeInsets.only(left: 4.0),
context.primaryColor.withValues(alpha: 0.5), child: ListTile(
context.primaryColor.withValues(alpha: 0.3), title: Text("advanced_settings_beta_timeline_title".t(context: context)),
_gradientAnimation.value, subtitle: Text("advanced_settings_beta_timeline_subtitle".t(context: context)),
)!, trailing: Switch.adaptive(
Color.lerp(
context.logoPink.withValues(alpha: 0.2),
context.logoPink.withValues(alpha: 0.4),
_gradientAnimation.value,
)!,
Color.lerp(
context.logoRed.withValues(alpha: 0.3),
context.logoRed.withValues(alpha: 0.5),
_gradientAnimation.value,
)!,
];
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
gradient: LinearGradient(
colors: gradientColors,
stops: const [0.0, 0.5, 1.0],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
transform: GradientRotation(_rotationAnimation.value * 0.5),
),
boxShadow: [
BoxShadow(color: context.primaryColor.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)),
],
),
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10.5)),
color: context.scaffoldBackgroundColor,
),
child: Material(
borderRadius: const BorderRadius.all(Radius.circular(10.5)),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(10.5)),
onTap: () => onSwitchChanged(!betaTimelineValue),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Transform.scale(
scale: _pulseAnimation.value,
child: Transform.rotate(
angle: _rotationAnimation.value * 0.02,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
context.primaryColor.withValues(alpha: 0.2),
context.primaryColor.withValues(alpha: 0.1),
],
),
),
child: Icon(Icons.auto_awesome, color: context.primaryColor, size: 20),
),
),
),
const SizedBox(width: 28),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"advanced_settings_beta_timeline_title".t(context: context),
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
gradient: LinearGradient(
colors: [
context.primaryColor.withValues(alpha: 0.8),
context.primaryColor.withValues(alpha: 0.6),
],
),
),
child: Text(
'NEW',
style: context.textTheme.labelSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 10,
height: 1.2,
),
),
),
],
),
const SizedBox(height: 4),
Text(
"advanced_settings_beta_timeline_subtitle".t(context: context),
style: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color?.withValues(alpha: 0.9),
),
maxLines: 2,
),
],
),
),
Switch.adaptive(
value: betaTimelineValue, value: betaTimelineValue,
onChanged: onSwitchChanged, onChanged: onSwitchChanged,
activeColor: context.primaryColor, activeColor: context.primaryColor,
), ),
], onTap: () => onSwitchChanged(!betaTimelineValue),
), ),
),
),
),
),
);
},
); );
} }
} }
@@ -16,6 +16,7 @@ import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class SharedLinkItem extends ConsumerWidget { class SharedLinkItem extends ConsumerWidget {
final SharedLink sharedLink; final SharedLink sharedLink;
@@ -36,7 +37,7 @@ class SharedLinkItem extends ConsumerWidget {
return Text("expired", style: TextStyle(color: Colors.red[300])).tr(); return Text("expired", style: TextStyle(color: Colors.red[300])).tr();
} }
final difference = sharedLink.expiresAt!.difference(DateTime.now()); final difference = sharedLink.expiresAt!.difference(DateTime.now());
debugPrint("Difference: $difference"); dPrint(() => "Difference: $difference");
if (difference.inDays > 0) { if (difference.inDays > 0) {
var dayDifference = difference.inDays; var dayDifference = difference.inDays;
if (difference.inHours % 24 > 12) { if (difference.inHours % 24 > 12) {
+2 -2
View File
@@ -77,10 +77,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: background_downloader name: background_downloader
sha256: "2d4c2b7438e7643585880f9cc00ace16a52d778088751f1bfbf714627b315462" sha256: "9ed74c55750932178f6989ba8a659687c2a102e05b70f561a1b3f047a5dda790"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.2.0" version: "9.2.5"
bonsoir: bonsoir:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -16,7 +16,7 @@ dependencies:
async: ^2.11.0 async: ^2.11.0
auto_route: ^9.2.0 auto_route: ^9.2.0
background_downloader: ^9.2.0 background_downloader: ^9.2.5
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
cancellation_token_http: ^2.1.0 cancellation_token_http: ^2.1.0
cast: ^2.1.0 cast: ^2.1.0
@@ -4,12 +4,15 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import '../../api.mocks.dart'; import '../../api.mocks.dart';
import '../../service.mocks.dart'; import '../../service.mocks.dart';
import '../../test_utils.dart';
class MockHttpClient extends Mock implements http.Client {} class MockHttpClient extends Mock implements http.Client {}
@@ -33,6 +36,10 @@ void main() {
late StreamController<List<int>> responseStreamController; late StreamController<List<int>> responseStreamController;
late int testBatchSize = 3; late int testBatchSize = 3;
setUpAll(() async {
await StoreService.init(storeRepository: IsarStoreRepository(await TestUtils.initIsar()));
});
setUp(() { setUp(() {
mockApiService = MockApiService(); mockApiService = MockApiService();
mockApiClient = MockApiClient(); mockApiClient = MockApiClient();
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/throttle.dart'; import 'package:immich_mobile/utils/throttle.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class _Counter { class _Counter {
int _count = 0; int _count = 0;
@@ -8,7 +8,7 @@ class _Counter {
int get count => _count; int get count => _count;
void increment() { void increment() {
debugPrint("Counter inside increment: $count"); dPrint(() => "Counter inside increment: $count");
_count = _count + 1; _count = _count + 1;
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@
"build:stats": "BUILD_STATS=true vite build", "build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package", "package": "svelte-kit package",
"preview": "vite preview", "preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings", "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte",
"check:typescript": "tsc --noEmit", "check:typescript": "tsc --noEmit",
"check:watch": "npm run check:svelte -- --watch", "check:watch": "npm run check:svelte -- --watch",
"check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript", "check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript",
@@ -48,7 +48,7 @@
aria-label={$t('show_album_options')} aria-label={$t('show_album_options')}
icon={mdiDotsVertical} icon={mdiDotsVertical}
shape="round" shape="round"
variant="ghost" variant="filled"
size="medium" size="medium"
class="icon-white-drop-shadow" class="icon-white-drop-shadow"
onclick={showAlbumContextMenu} onclick={showAlbumContextMenu}
@@ -4,7 +4,6 @@
import AlbumMap from '$lib/components/album-page/album-map.svelte'; import AlbumMap from '$lib/components/album-page/album-map.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import Timeline from '$lib/components/timeline/timeline.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -19,6 +18,7 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import DownloadAction from '../photos-page/actions/download-action.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte'; import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
import ThemeButton from '../shared-components/theme-button.svelte'; import ThemeButton from '../shared-components/theme-button.svelte';
@@ -61,7 +61,7 @@
/> />
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)"> <main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
<Timeline enableRouting={true} {album} {timelineManager} {assetInteraction}> <AssetGrid enableRouting={true} {album} {timelineManager} {assetInteraction}>
<section class="pt-8 md:pt-24 px-2 md:px-0"> <section class="pt-8 md:pt-24 px-2 md:px-0">
<!-- ALBUM TITLE --> <!-- ALBUM TITLE -->
<h1 <h1
@@ -83,7 +83,7 @@
</p> </p>
{/if} {/if}
</section> </section>
</Timeline> </AssetGrid>
</main> </main>
<header> <header>
@@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import { import {
NotificationType, NotificationType,
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import Portal from '$lib/components/shared-components/portal/portal.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte';
import DeleteAssetDialog from '$lib/components/timeline/actions/delete-asset-dialog.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk'; import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js'; import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action'; import type { OnAction, PreAction } from './action';
import { IconButton } from '@immich/ui';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@@ -45,15 +45,4 @@ describe('Thumbnail component', () => {
const tabbables = getTabbable(container!); const tabbables = getTabbable(container!);
expect(tabbables.length).toBe(0); expect(tabbables.length).toBe(0);
}); });
it('shows thumbhash while image is loading', () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const sut = render(Thumbnail, {
asset,
selected: true,
});
const thumbhash = sut.getByTestId('thumbhash');
expect(thumbhash).not.toBeFalsy();
});
}); });
@@ -20,6 +20,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isCached } from '$lib/utils/cache';
import { moveFocus } from '$lib/utils/focus-util'; import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
@@ -75,6 +76,12 @@
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES; } = TUNABLES;
const thumbnailURL = getAssetThumbnailUrl({
id: asset.id,
size: AssetMediaSize.Thumbnail,
cacheKey: asset.thumbhash,
});
let usingMobileDevice = $derived(mobileDevice.pointerCoarse); let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let element: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();
let mouseOver = $state(false); let mouseOver = $state(false);
@@ -313,7 +320,7 @@
<ImageThumbnail <ImageThumbnail
class={imageClass} class={imageClass}
{brokenAssetClass} {brokenAssetClass}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })} url={thumbnailURL}
altText={$getAltText(asset)} altText={$getAltText(asset)}
widthStyle="{width}px" widthStyle="{width}px"
heightStyle="{height}px" heightStyle="{height}px"
@@ -344,7 +351,19 @@
</div> </div>
{/if} {/if}
{#if (!loaded || thumbError) && asset.thumbhash} {#if asset.thumbhash}
{#await isCached(new Request(thumbnailURL))}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
data-testid="thumbhash"
class="absolute top-0 object-cover"
style:width="{width}px"
style:height="{height}px"
class:rounded-xl={selected}
draggable="false"
></canvas>
{:then cached}
{#if !cached && !loaded && !thumbError}
<canvas <canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }} use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
data-testid="thumbhash" data-testid="thumbhash"
@@ -356,6 +375,8 @@
out:fade={{ duration: THUMBHASH_FADE_DURATION }} out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas> ></canvas>
{/if} {/if}
{/await}
{/if}
</div> </div>
{#if selectionCandidate} {#if selectionCandidate}
@@ -110,7 +110,7 @@
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} /> <svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<div <div
class="fixed top-0 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8" class="fixed top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
> >
<div class="flex items-center"> <div class="flex items-center">
<IconButton <IconButton
@@ -64,9 +64,10 @@
{#if showVerticalDots} {#if showVerticalDots}
<div class="absolute top-2 end-2 z-1"> <div class="absolute top-2 end-2 z-1">
<ButtonContextMenu <ButtonContextMenu
buttonClass="icon-white-drop-shadow focus:opacity-100 {showVerticalDots ? 'opacity-100' : 'opacity-0'}" buttonClass="icon-white-drop-shadow"
color="primary" color="secondary"
size="medium" size="medium"
variant="filled"
icon={mdiDotsVertical} icon={mdiDotsVertical}
title={$t('show_person_options')} title={$t('show_person_options')}
> >
@@ -68,7 +68,7 @@
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark"> <div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
{#if title} {#if title}
<div class="font-medium outline-none" tabindex="-1" id={headerId}>{title}</div> <div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>
{/if} {/if}
{#if description} {#if description}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p> <p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions'; import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
import { IconButton } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js'; import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import DeleteAssetDialog from '../../timeline/actions/delete-asset-dialog.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import DeleteAssetDialog from '../delete-asset-dialog.svelte';
import { IconButton } from '@immich/ui';
interface Props { interface Props {
onAssetDelete: OnDelete; onAssetDelete: OnDelete;
@@ -0,0 +1,254 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import Icon from '$lib/components/elements/icon.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 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 path={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon path={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>
File diff suppressed because it is too large Load Diff
@@ -1,162 +0,0 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
let { asset: viewingAsset, gridScrollTarget, mutex } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
showSkeleton: boolean;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE;
handlePreAction?: (action: Action) => Promise<void>;
handleAction?: (action: Action) => void;
handleNext?: () => Promise<boolean>;
handlePrevious?: () => Promise<boolean>;
handleRandom?: () => Promise<AssetResponseDto | undefined>;
handleClose?: (asset: { id: string }) => Promise<void>;
}
let {
timelineManager = $bindable(),
showSkeleton = $bindable(false),
removeAction,
handlePreAction = $bindable(),
handleAction = $bindable(),
handleNext = $bindable(),
handlePrevious = $bindable(),
handleRandom = $bindable(),
handleClose = $bindable(),
}: Props = $props();
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;
};
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;
};
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;
}
};
handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
break;
}
}
};
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>
@@ -1,73 +0,0 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import AssetViewerActions from '$lib/components/photos-page/asset-viewer-actions.svelte';
import { AssetAction } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
let { asset: viewingAsset, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
showSkeleton: boolean;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
}
let {
timelineManager = $bindable(),
showSkeleton = $bindable(false),
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
isShowDeleteConfirmation = $bindable(false),
}: Props = $props();
let handlePreAction = <(action: Action) => Promise<void>>$state();
let handleAction = <(action: Action) => void>$state();
let handleNext = <() => Promise<boolean>>$state();
let handlePrevious = <() => Promise<boolean>>$state();
let handleRandom = <() => Promise<AssetResponseDto | undefined>>$state();
let handleClose = <(asset: { id: string }) => Promise<void>>$state();
</script>
<AssetViewerActions
{timelineManager}
{removeAction}
bind:showSkeleton
bind:handlePreAction
bind:handleAction
bind:handleNext
bind:handlePrevious
bind:handleRandom
bind:handleClose
></AssetViewerActions>
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/await}
@@ -13,7 +13,11 @@
> >
{title} {title}
</div> </div>
<div class="animate-pulse absolute h-full w-full" data-skeleton="true"></div> <div
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
style:width="calc(100% - 20px)"
data-skeleton="true"
></div>
</div> </div>
<style> <style>
@@ -8,9 +8,6 @@ import ChangeDate from './change-date.svelte';
describe('ChangeDate component', () => { describe('ChangeDate component', () => {
const initialDate = DateTime.fromISO('2024-01-01'); const initialDate = DateTime.fromISO('2024-01-01');
const initialTimeZone = 'Europe/Berlin'; const initialTimeZone = 'Europe/Berlin';
const targetDate = DateTime.fromISO('2024-01-01').setZone('UTC+1', {
keepLocalTime: true,
});
const currentInterval = { const currentInterval = {
start: DateTime.fromISO('2000-02-01T14:00:00+01:00'), start: DateTime.fromISO('2000-02-01T14:00:00+01:00'),
end: DateTime.fromISO('2001-02-01T14:00:00+01:00'), end: DateTime.fromISO('2001-02-01T14:00:00+01:00'),
@@ -46,11 +43,7 @@ describe('ChangeDate component', () => {
await fireEvent.click(getConfirmButton()); await fireEvent.click(getConfirmButton());
expect(onConfirm).toHaveBeenCalledWith({ expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-01-01T00:00:00.000+01:00' });
mode: 'absolute',
date: '2024-01-01T00:00:00.000+01:00',
dateTime: targetDate,
});
}); });
test('calls onCancel on cancel', async () => { test('calls onCancel on cancel', async () => {
@@ -65,9 +58,7 @@ describe('ChangeDate component', () => {
describe('when date is in daylight saving time', () => { describe('when date is in daylight saving time', () => {
const dstDate = DateTime.fromISO('2024-07-01'); const dstDate = DateTime.fromISO('2024-07-01');
const targetDate = DateTime.fromISO('2024-07-01').setZone('UTC+2', {
keepLocalTime: true,
});
test('should render correct timezone with offset', () => { test('should render correct timezone with offset', () => {
render(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm }); render(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm });
@@ -81,11 +72,7 @@ describe('ChangeDate component', () => {
await fireEvent.click(getConfirmButton()); await fireEvent.click(getConfirmButton());
expect(onConfirm).toHaveBeenCalledWith({ expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-07-01T00:00:00.000+02:00' });
mode: 'absolute',
date: '2024-07-01T00:00:00.000+02:00',
dateTime: targetDate,
});
}); });
}); });
@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import { locale } from '$lib/stores/preferences.store'; import { ConfirmModal } from '@immich/ui';
import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js'; import { mdiCalendarEditOutline } from '@mdi/js';
import { ConfirmModal, Field, Switch } from '@immich/ui';
import { mdiCalendarEdit } from '@mdi/js';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import DateInput from '../elements/date-input.svelte'; import DateInput from '../elements/date-input.svelte';
import DurationInput from '../elements/duration-input.svelte';
import Combobox, { type ComboBoxOption } from './combobox.svelte'; import Combobox, { type ComboBoxOption } from './combobox.svelte';
import DurationInput from '../elements/duration-input.svelte';
import { Field, Switch } from '@immich/ui';
import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js';
import { locale } from '$lib/stores/preferences.store';
import { get } from 'svelte/store';
interface Props { interface Props {
title?: string; title?: string;
@@ -17,8 +18,6 @@
timezoneInput?: boolean; timezoneInput?: boolean;
withDuration?: boolean; withDuration?: boolean;
currentInterval?: { start: DateTime; end: DateTime }; currentInterval?: { start: DateTime; end: DateTime };
icon?: string;
confirmText?: string;
onCancel: () => void; onCancel: () => void;
onConfirm: (result: AbsoluteResult | RelativeResult) => void; onConfirm: (result: AbsoluteResult | RelativeResult) => void;
} }
@@ -30,8 +29,6 @@
timezoneInput = true, timezoneInput = true,
withDuration = true, withDuration = true,
currentInterval = undefined, currentInterval = undefined,
icon = mdiCalendarEdit,
confirmText,
onCancel, onCancel,
onConfirm, onConfirm,
}: Props = $props(); }: Props = $props();
@@ -39,7 +36,6 @@
export type AbsoluteResult = { export type AbsoluteResult = {
mode: 'absolute'; mode: 'absolute';
date: string; date: string;
dateTime: DateTime<true>;
}; };
export type RelativeResult = { export type RelativeResult = {
@@ -196,13 +192,9 @@
const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`; const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
// Create a DateTime object in this fixed-offset zone, preserving the local time. // Create a DateTime object in this fixed-offset zone, preserving the local time.
const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone }) as DateTime<true>; const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
onConfirm({ onConfirm({ mode: 'absolute', date: finalDateTime.toISO({ includeOffset: true })! });
mode: 'absolute',
date: finalDateTime.toISO({ includeOffset: true }),
dateTime: finalDateTime,
});
} }
if (showRelative && (selectedDuration || selectedRelativeOption)) { if (showRelative && (selectedDuration || selectedRelativeOption)) {
@@ -246,8 +238,7 @@
<ConfirmModal <ConfirmModal
confirmColor="primary" confirmColor="primary"
{title} {title}
{icon} icon={mdiCalendarEditOutline}
{confirmText}
prompt="Please select a new date:" prompt="Please select a new date:"
disabled={!date.isValid} disabled={!date.isValid}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())} onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
@@ -40,6 +40,8 @@
// of zero when starting the 'slide' animation. // of zero when starting the 'slide' animation.
let height: number = $state(0); let height: number = $state(0);
let isTransitioned = $state(false);
$effect(() => { $effect(() => {
if (menuElement) { if (menuElement) {
let layoutDirection = direction; let layoutDirection = direction;
@@ -64,6 +66,12 @@
style:top="{top}px" style:top="{top}px"
transition:slide={{ duration: 250, easing: quintOut }} transition:slide={{ duration: 250, easing: quintOut }}
use:clickOutside={{ onOutclick: onClose }} use:clickOutside={{ onOutclick: onClose }}
onintroend={() => {
isTransitioned = true;
}}
onoutrostart={() => {
isTransitioned = false;
}}
> >
<ul <ul
{id} {id}
@@ -73,7 +81,9 @@
bind:this={menuElement} bind:this={menuElement}
class="{isVisible class="{isVisible
? 'max-h-dvh' ? 'max-h-dvh'
: 'max-h-0'} flex flex-col transition-all duration-250 ease-in-out outline-none overflow-auto" : 'max-h-0'} flex flex-col transition-all duration-250 ease-in-out outline-none {isTransitioned
? 'overflow-auto'
: ''}"
role="menu" role="menu"
tabindex="-1" tabindex="-1"
> >
@@ -23,8 +23,7 @@
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
import DeleteAssetDialog from '$lib/components/timeline/actions/delete-asset-dialog.svelte';
import Portal from '../portal/portal.svelte'; import Portal from '../portal/portal.svelte';
interface Props { interface Props {
@@ -353,7 +353,7 @@
<div <div
class="rounded-full w-[40px] h-[40px] bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90" class="rounded-full w-[40px] h-[40px] bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
> >
{feature.properties?.point_count} {feature.properties?.point_count?.toLocaleString()}
</div> </div>
{/snippet} {/snippet}
</MarkerLayer> </MarkerLayer>
@@ -4,38 +4,25 @@
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types'; import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util'; import { getTabbable } from '$lib/utils/focus-util';
import { type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { type ScrubberListener } from '$lib/utils/timeline-util';
import { mdiPlay } from '@mdi/js'; import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
interface Props { interface Props {
/** Offset from the top of the timeline (e.g., for headers) */
timelineTopOffset?: number; timelineTopOffset?: number;
/** Offset from the bottom of the timeline (e.g., for footers) */
timelineBottomOffset?: number; timelineBottomOffset?: number;
/** Total height of the scrubber component */
height?: number; height?: number;
/** Timeline manager instance that controls the timeline state */
timelineManager: TimelineManager; timelineManager: TimelineManager;
/** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */ scrubOverallPercent?: number;
timelineScrollPercent?: number; scrubberMonthPercent?: number;
/** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */ scrubberMonth?: { year: number; month: number };
viewportTopMonthScrollPercent?: number; leadout?: boolean;
/** The year/month of the timeline month at the top of the viewport */
viewportTopMonth?: TimelineYearMonth;
/** Indicates whether the viewport is currently in the lead-out section (after all months) */
isInLeadOutSection?: boolean;
/** Width of the scrubber component in pixels (bindable for parent component margin adjustments) */
scrubberWidth?: number; scrubberWidth?: number;
/** Callback fired when user interacts with the scrubber to navigate */
onScrub?: ScrubberListener; onScrub?: ScrubberListener;
/** Callback fired when keyboard events occur on the scrubber */
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void; onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
/** Callback fired when scrubbing starts */
startScrub?: ScrubberListener; startScrub?: ScrubberListener;
/** Callback fired when scrubbing stops */
stopScrub?: ScrubberListener; stopScrub?: ScrubberListener;
} }
@@ -44,10 +31,10 @@
timelineBottomOffset = 0, timelineBottomOffset = 0,
height = 0, height = 0,
timelineManager, timelineManager,
timelineScrollPercent = 0, scrubOverallPercent = 0,
viewportTopMonthScrollPercent = 0, scrubberMonthPercent = 0,
viewportTopMonth = undefined, scrubberMonth = undefined,
isInLeadOutSection = false, leadout = false,
onScrub = undefined, onScrub = undefined,
onScrubKeyDown = undefined, onScrubKeyDown = undefined,
startScrub = undefined, startScrub = undefined,
@@ -113,7 +100,7 @@
offset += scrubberMonthPercent * relativeBottomOffset; offset += scrubberMonthPercent * relativeBottomOffset;
} }
return offset; return offset;
} else if (isInLeadOutSection) { } else if (leadout) {
let offset = relativeTopOffset; let offset = relativeTopOffset;
for (const segment of segments) { for (const segment of segments) {
offset += segment.height; offset += segment.height;
@@ -124,9 +111,7 @@
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM)); return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
} }
}; };
let scrollY = $derived( let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent));
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
);
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset); let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight)); let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight)); let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
@@ -310,24 +295,12 @@
const scrollPercent = toTimelineY(hoverY); const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) { if (wasDragging === false && isDragging) {
void startScrub?.({ void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
scrubberMonth: segmentDate!, void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
void onScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
} }
if (wasDragging && !isDragging) { if (wasDragging && !isDragging) {
void stopScrub?.({ void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
return; return;
} }
@@ -335,11 +308,7 @@
return; return;
} }
void onScrub?.({ void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
}; };
/* eslint-disable tscompat/tscompat */ /* eslint-disable tscompat/tscompat */
const getTouch = (event: TouchEvent) => { const getTouch = (event: TouchEvent) => {
@@ -443,11 +412,7 @@
} }
if (next) { if (next) {
event.preventDefault(); event.preventDefault();
void onScrub?.({ void onScrub?.({ year: next.year, month: next.month }, -1, 0);
scrubberMonth: { year: next.year, month: next.month },
overallScrollPercent: -1,
scrubberMonthScrollPercent: 0,
});
return true; return true;
} }
} }
@@ -457,11 +422,7 @@
const next = segments[idx + 1]; const next = segments[idx + 1];
if (next) { if (next) {
event.preventDefault(); event.preventDefault();
void onScrub?.({ void onScrub?.({ year: next.year, month: next.month }, -1, 0);
scrubberMonth: { year: next.year, month: next.month },
overallScrollPercent: -1,
scrubberMonthScrollPercent: 0,
});
return true; return true;
} }
} }
@@ -1,225 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
} from '$lib/components/photos-page/actions/focus-actions';
import ChangeDate, {
type AbsoluteResult,
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
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 { mdiCalendarBlankOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
let { isViewing: showAssetViewer } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
isShowDeleteConfirmation: boolean;
onEscape?: () => void;
scrollToAsset: (asset: TimelineAsset) => boolean;
}
let {
timelineManager = $bindable(),
assetInteraction,
isShowDeleteConfirmation = $bindable(false),
onEscape,
scrollToAsset,
}: Props = $props();
let isShowSelectDate = $state(false);
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.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 = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
})(),
);
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={idsSelectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}
{#if isShowSelectDate}
<ChangeDate
withDuration={false}
icon={mdiCalendarBlankOutline}
confirmText={$t('navigate')}
title={$t('navigate_to_time')}
initialDate={DateTime.now()}
timezoneInput={false}
onConfirm={async (result: AbsoluteResult | RelativeResult) => {
isShowSelectDate = false;
if (result.mode === 'absolute') {
const asset = await timelineManager.getClosestAssetToDate(
(DateTime.fromISO(result.date) as DateTime<true>).toObject(),
);
if (asset) {
setFocusAsset(asset);
}
}
}}
onCancel={() => (isShowSelectDate = false)}
/>
{/if}
@@ -1,318 +0,0 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Hmr from '$lib/components/timeline/base-components/hmr.svelte';
import Skeleton from '$lib/components/timeline/base-components/skeleton.svelte';
import SelectableTimelineMonth from '$lib/components/timeline/internal-components/selectable-timeline-month.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
isSelectionMode?: boolean;
singleSelect?: 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: TimelineManager;
assetInteraction: AssetInteraction;
withStacked?: boolean;
showArchiveIcon?: boolean;
showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean;
styleMarginRightOverride?: string;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (asset: TimelineAsset) => void;
header?: Snippet<[scrollToFunction: (top: number) => void]>;
children?: Snippet;
empty?: Snippet;
handleTimelineScroll?: () => void;
}
let {
customThumbnailLayout,
isSelectionMode = false,
singleSelect = false,
enableRouting,
timelineManager = $bindable(),
assetInteraction,
withStacked = false,
showSkeleton = $bindable(true),
showArchiveIcon = false,
styleMarginRightOverride,
isShowDeleteConfirmation = $bindable(false),
onAssetOpen,
onSelect,
children,
empty,
header,
handleTimelineScroll = () => {},
}: 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
? {
rowHeight: 100,
headerHeight: 32,
}
: {
rowHeight: 235,
headerHeight: 48,
};
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 scrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => {
const { heightDelta, scrollTop } = compensation;
if (heightDelta !== undefined) {
scrollBy(heightDelta);
} else if (scrollTop !== undefined) {
scrollTo(scrollTop);
}
timelineManager.clearScrollCompensation();
};
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
// the following method may trigger any layouts, so need to
// handle any scroll compensation that may have been set
const height = monthGroup!.findAssetAbsolutePosition(assetId);
// this is in a while loop, since scrollCompensations invoke scrolls
// which may load months, triggering more scrollCompensations. Call
// this in a loop, until no more layouts occur.
while (timelineManager.scrollCompensation.monthGroup) {
scrollCompensation(timelineManager.scrollCompensation);
}
return height;
};
const assetIsVisible = (assetTop: number): boolean => {
if (!element) {
return false;
}
const { clientHeight, scrollTop } = element;
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
};
const scrollToAssetId = async (assetId: string) => {
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(assetId, monthGroup);
// If the asset is already visible, then don't scroll.
if (assetIsVisible(height)) {
return true;
}
scrollTo(height);
return true;
};
export const scrollToAsset = (asset: TimelineAsset) => {
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(asset.id, monthGroup);
scrollTo(height);
return true;
};
const completeNav = async () => {
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;
};
beforeNavigate(() => (timelineManager.suspendTransitions = true));
afterNavigate((nav) => {
const { complete } = nav;
complete.then(completeNav, completeNav);
});
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>
<Hmr
onAfterUpdate={(payload: UpdatePayload) => {
// 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 completeNav();
};
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('base-timeline-viewer.svelte'));
if (assetGridUpdate) {
// 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 -->
<section
id="asset-grid"
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
style:margin-right={styleMarginRightOverride}
tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
bind:this={element}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
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.viewId)}
{@const display = monthGroup.intersecting}
{@const absoluteHeight = monthGroup.top}
{#if !monthGroup.isLoaded}
<div
style:height={monthGroup.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<Skeleton
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
title={monthGroup.monthGroupTitle}
/>
</div>
{:else if display}
<div
class="month-group"
style:height={monthGroup.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<SelectableTimelineMonth
{customThumbnailLayout}
{withStacked}
{showArchiveIcon}
{assetInteraction}
{timelineManager}
{isSelectionMode}
{singleSelect}
{monthGroup}
{onAssetOpen}
onSelect={(isSingleSelect: boolean, asset: TimelineAsset) => {
if (isSingleSelect) {
scrollTo(0);
}
onSelect?.(asset);
}}
onScrollCompensationMonthInDOM={scrollCompensation}
/>
</div>
{/if}
{/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>
</section>
<style>
#asset-grid {
contain: strict;
scrollbar-width: none;
}
.month-group {
contain: layout size paint;
transform-style: flat;
backface-visibility: hidden;
transform-origin: center center;
}
</style>
@@ -1,200 +0,0 @@
<script lang="ts">
import BaseTimelineViewer from '$lib/components/timeline/base-components/base-timeline-viewer.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 type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { findMonthAtScrollPosition, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import type { Snippet } from 'svelte';
import Scrubber from './scrubber.svelte';
interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
isSelectionMode?: boolean;
singleSelect?: boolean;
/** `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;
assetInteraction: AssetInteraction;
withStacked?: boolean;
showArchiveIcon?: boolean;
showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (asset: TimelineAsset) => void;
children?: Snippet;
empty?: Snippet;
}
let {
customThumbnailLayout,
isSelectionMode = false,
singleSelect = false,
enableRouting,
timelineManager = $bindable(),
assetInteraction,
withStacked = false,
showArchiveIcon = false,
showSkeleton = $bindable(true),
isShowDeleteConfirmation = $bindable(false),
onAssetOpen,
onSelect = () => {},
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: ScrubberListener = (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: BaseTimelineViewer | undefined = $state();
export const scrollToAsset = (asset: TimelineAsset) => baseTimelineViewer?.scrollToAsset(asset) ?? false;
</script>
<BaseTimelineViewer
{customThumbnailLayout}
{isSelectionMode}
{singleSelect}
{enableRouting}
{timelineManager}
{assetInteraction}
{withStacked}
{showArchiveIcon}
{showSkeleton}
{isShowDeleteConfirmation}
styleMarginRightOverride={scrubberWidth + 'px'}
{onAssetOpen}
{onSelect}
{children}
{empty}
{handleTimelineScroll}
>
{#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}
</BaseTimelineViewer>
@@ -1,16 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
onAfterUpdate: (payload: UpdatePayload) => void;
}
let { onAfterUpdate }: Props = $props();
onMount(() => {
if (import.meta && import.meta.hot) {
import.meta.hot.on('vite:afterUpdate', onAfterUpdate);
return () => import.meta.hot && import.meta.hot.off('vite:afterUpdate', onAfterUpdate);
}
});
</script>
@@ -1,181 +0,0 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import Icon from '$lib/components/elements/icon.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 { uploadAssetsStore } from '$lib/stores/upload';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { flip } from 'svelte/animate';
import { fly, scale } from 'svelte/transition';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { Snippet } from 'svelte';
let { isUploading } = uploadAssetsStore;
interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
onHover: (dayGroup: DayGroup, asset: TimelineAsset) => void;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset) => void;
onAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
// these should be replaced with reactive properties in timeline-manager.svelte.ts
isDayGroupSelected: (dayGroup: DayGroup) => boolean;
isAssetSelected: (asset: TimelineAsset) => boolean;
isAssetSelectionCandidate: (asset: TimelineAsset) => boolean;
isAssetDisabled: (asset: TimelineAsset) => boolean;
}
let {
customThumbnailLayout,
singleSelect,
withStacked,
showArchiveIcon,
monthGroup,
timelineManager,
onScrollCompensationMonthInDOM,
onHover,
onAssetOpen,
onAssetSelect,
onDayGroupSelect,
isDayGroupSelected,
isAssetSelected,
isAssetSelectionCandidate,
isAssetDisabled,
}: 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);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting);
}
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
}
});
</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;
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 && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || isDayGroupSelected(dayGroup))}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block pe-2 hover:cursor-pointer"
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
>
{#if isDayGroupSelected(dayGroup)}
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon path={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={dayGroup.groupTitleFull}>
{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!}
<!-- 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={() => onAssetOpen?.(dayGroup, assetSnapshot(asset))}
onSelect={() => onAssetSelect(dayGroup, assetSnapshot(asset))}
onMouseEvent={() => onHover(dayGroup, assetSnapshot(asset))}
selected={isAssetSelected(asset)}
selectionCandidate={isAssetSelectionCandidate(asset)}
disabled={isAssetDisabled(asset)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{#if customThumbnailLayout}
{@render customThumbnailLayout(asset)}
{/if}
</div>
{/each}
</div>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
[data-image-grid] {
user-select: none;
}
</style>
@@ -1,276 +0,0 @@
<script lang="ts">
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import TimelineMonth from '$lib/components/timeline/base-components/timeline-month.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import type { Snippet } from 'svelte';
interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
isSelectionMode: boolean;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (isSingleSelect: boolean, asset: TimelineAsset) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
}
let {
customThumbnailLayout,
isSelectionMode,
singleSelect,
withStacked,
showArchiveIcon,
monthGroup = $bindable(),
assetInteraction,
timelineManager,
onAssetOpen,
onSelect,
onScrollCompensationMonthInDOM,
}: Props = $props();
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false);
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
$effect(() => {
if (!lastAssetMouseEvent || !lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
}
if (shiftKeyIsDown && lastAssetMouseEvent) {
void selectAssetCandidates(lastAssetMouseEvent);
}
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const defaultAssetOpen = (dayGroup: DayGroup, asset: TimelineAsset) => {
if (isSelectionMode || assetInteraction.selectionActive) {
handleAssetSelect(dayGroup, asset);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleOnAssetOpen = (dayGroup: DayGroup, asset: TimelineAsset) => {
if (onAssetOpen) {
onAssetOpen(dayGroup, asset, () => defaultAssetOpen(dayGroup, asset));
return;
}
defaultAssetOpen(dayGroup, asset);
};
// called when clicking asset with shift key pressed or with mouse
const handleAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => {
void onSelectAssets(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
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 handleSelectAsset = (asset: TimelineAsset) => {
if (!timelineManager.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
};
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 handleOnHover = (dayGroup: DayGroup, asset: TimelineAsset) => {
if (assetInteraction.selectionActive) {
void selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const handleDayGroupSelect = (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) {
handleSelectAsset(asset);
}
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const onSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
onSelect?.(singleSelect, asset);
if (singleSelect) {
// onScrollToTop();
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
if (startBucket === null || endBucket === null) {
return;
}
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
}
if (monthGroup === endBucket) {
break;
}
}
}
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
<TimelineMonth
{customThumbnailLayout}
{singleSelect}
{withStacked}
{showArchiveIcon}
{monthGroup}
{timelineManager}
{onScrollCompensationMonthInDOM}
onHover={handleOnHover}
onAssetOpen={handleOnAssetOpen}
onAssetSelect={handleAssetSelect}
onDayGroupSelect={handleDayGroupSelect}
isDayGroupSelected={(dayGroup: DayGroup) => assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
isAssetSelected={(asset) => assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
isAssetSelectionCandidate={(asset) => assetInteraction.hasSelectionCandidate(asset.id)}
isAssetDisabled={(asset) => timelineManager.albumAssets.has(asset.id)}
/>
@@ -1,176 +0,0 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
showSkeleton: boolean;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE;
}
let {
timelineManager,
showSkeleton = $bindable(false),
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
}: Props = $props();
const handlePrevious = async () => {
const release = await mutex.acquire();
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
release();
return !!laterAsset;
};
const handleNext = async () => {
const release = await mutex.acquire();
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
release();
return !!earlierAsset;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
}
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
break;
}
}
};
const handleAction = (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
timelineManager.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={handleClose}
/>
{/await}
@@ -1,101 +0,0 @@
<script lang="ts">
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/timeline-keyboard-actions.svelte';
import BaseTimeline from '$lib/components/timeline/base-components/base-timeline.svelte';
import TimelineAssetViewer from '$lib/components/timeline/internal-components/timeline-asset-viewer.svelte';
import type { AssetAction } from '$lib/constants';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import type { Snippet } from 'svelte';
let { isViewing: showAssetViewer } = assetViewingStore;
interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
isSelectionMode?: boolean;
singleSelect?: boolean;
/** `true` if this asset grid is 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;
assetInteraction: AssetInteraction;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE;
withStacked?: boolean;
showArchiveIcon?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void;
children?: Snippet;
empty?: Snippet;
}
let {
customThumbnailLayout,
isSelectionMode = false,
singleSelect = false,
enableRouting,
timelineManager = $bindable(),
assetInteraction,
removeAction,
withStacked = false,
showArchiveIcon = false,
isShared = false,
album = null,
person = null,
isShowDeleteConfirmation = $bindable(false),
onAssetOpen,
onSelect = () => {},
onEscape = () => {},
children,
empty,
}: Props = $props();
let viewer: BaseTimeline | undefined = $state();
let showSkeleton: boolean = $state(true);
</script>
<BaseTimeline
bind:this={viewer}
{customThumbnailLayout}
{isSelectionMode}
{singleSelect}
{enableRouting}
{timelineManager}
{assetInteraction}
{withStacked}
{showArchiveIcon}
{isShowDeleteConfirmation}
{showSkeleton}
{onAssetOpen}
{onSelect}
{children}
{empty}
/>
<TimelineKeyboardActions
scrollToAsset={(asset) => viewer?.scrollToAsset(asset) ?? false}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
{onEscape}
/>
<Portal target="body">
{#if $showAssetViewer}
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
{/if}
</Portal>
@@ -13,7 +13,6 @@ export class DayGroup {
readonly monthGroup: MonthGroup; readonly monthGroup: MonthGroup;
readonly index: number; readonly index: number;
readonly groupTitle: string; readonly groupTitle: string;
readonly groupTitleFull: string;
readonly day: number; readonly day: number;
viewerAssets: ViewerAsset[] = $state([]); viewerAssets: ViewerAsset[] = $state([]);
@@ -27,12 +26,11 @@ export class DayGroup {
#col = $state(0); #col = $state(0);
#deferredLayout = false; #deferredLayout = false;
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string, groupTitleFull: string) { constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) {
this.index = index; this.index = index;
this.monthGroup = monthGroup; this.monthGroup = monthGroup;
this.day = day; this.day = day;
this.groupTitle = groupTitle; this.groupTitle = groupTitle;
this.groupTitleFull = groupTitleFull;
} }
get top() { get top() {
@@ -143,24 +143,3 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
} }
} }
} }
export function findClosestGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
let closestMonth: MonthGroup | undefined;
let minDifference = Number.MAX_SAFE_INTEGER;
for (const month of timelineManager.months) {
const { year, month: monthNum } = month.yearMonth;
// Calculate the absolute difference in months
const yearDiff = Math.abs(year - targetYearMonth.year);
const monthDiff = Math.abs(monthNum - targetYearMonth.month);
const totalDiff = yearDiff * 12 + monthDiff;
if (totalDiff < minDifference) {
minDifference = totalDiff;
closestMonth = month;
}
}
return closestMonth;
}

Some files were not shown because too many files have changed in this diff Show More