merge main

This commit is contained in:
Alex 2025-06-16 12:49:45 -05:00
commit 4b4d1e016b
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
91 changed files with 3561 additions and 500 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -11,11 +11,24 @@ describe('/people', () => {
let hiddenPerson: PersonResponseDto; let hiddenPerson: PersonResponseDto;
let multipleAssetsPerson: PersonResponseDto; let multipleAssetsPerson: PersonResponseDto;
let nameAlicePerson: PersonResponseDto;
let nameBobPerson: PersonResponseDto;
let nameCharliePerson: PersonResponseDto;
let nameNullPerson: PersonResponseDto;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
[visiblePerson, hiddenPerson, multipleAssetsPerson] = await Promise.all([ [
visiblePerson,
hiddenPerson,
multipleAssetsPerson,
nameCharliePerson,
nameBobPerson,
nameAlicePerson,
nameNullPerson,
] = await Promise.all([
utils.createPerson(admin.accessToken, { utils.createPerson(admin.accessToken, {
name: 'visible_person', name: 'visible_person',
}), }),
@ -26,10 +39,24 @@ describe('/people', () => {
utils.createPerson(admin.accessToken, { utils.createPerson(admin.accessToken, {
name: 'multiple_assets_person', name: 'multiple_assets_person',
}), }),
// --- Setup for the specific sorting test ---
utils.createPerson(admin.accessToken, {
name: 'Charlie',
}),
utils.createPerson(admin.accessToken, {
name: 'Bob',
}),
utils.createPerson(admin.accessToken, {
name: 'Alice',
}),
utils.createPerson(admin.accessToken, {
name: '',
}),
]); ]);
const asset1 = await utils.createAsset(admin.accessToken); const asset1 = await utils.createAsset(admin.accessToken);
const asset2 = await utils.createAsset(admin.accessToken); const asset2 = await utils.createAsset(admin.accessToken);
const asset3 = await utils.createAsset(admin.accessToken);
await Promise.all([ await Promise.all([
utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }), utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
@ -37,6 +64,15 @@ describe('/people', () => {
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }), utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }),
// Named persons
utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset
utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }),
utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets
utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset
// Null-named person
utils.createFace({ assetId: asset1.id, personId: nameNullPerson.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson.id }), // 2 assets
]); ]);
}); });
@ -51,26 +87,53 @@ describe('/people', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toEqual({
hasNextPage: false, hasNextPage: false,
total: 3, total: 7,
hidden: 1, hidden: 1,
people: [ people: [
expect.objectContaining({ name: 'multiple_assets_person' }), expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'Bob' }),
expect.objectContaining({ name: 'Alice' }),
expect.objectContaining({ name: 'Charlie' }),
expect.objectContaining({ name: 'visible_person' }), expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ name: 'hidden_person' }), expect.objectContaining({ name: 'hidden_person' }),
], ],
}); });
}); });
it('should sort visible people by asset count (desc), then by name (asc, nulls last)', async () => {
const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body.hasNextPage).toBe(false);
expect(body.total).toBe(7); // All persons
expect(body.hidden).toBe(1); // 'hidden_person'
const people = body.people as PersonResponseDto[];
expect(people.map((p) => p.id)).toEqual([
multipleAssetsPerson.id, // name: 'multiple_assets_person', count: 3
nameBobPerson.id, // name: 'Bob', count: 2
nameAlicePerson.id, // name: 'Alice', count: 1
nameCharliePerson.id, // name: 'Charlie', count: 1
visiblePerson.id, // name: 'visible_person', count: 1
]);
expect(people.some((p) => p.id === hiddenPerson.id)).toBe(false);
});
it('should return only visible people', async () => { it('should return only visible people', async () => {
const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`); const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toEqual({
hasNextPage: false, hasNextPage: false,
total: 3, total: 7,
hidden: 1, hidden: 1,
people: [ people: [
expect.objectContaining({ name: 'multiple_assets_person' }), expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'Bob' }),
expect.objectContaining({ name: 'Alice' }),
expect.objectContaining({ name: 'Charlie' }),
expect.objectContaining({ name: 'visible_person' }), expect.objectContaining({ name: 'visible_person' }),
], ],
}); });
@ -80,12 +143,12 @@ describe('/people', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/people') .get('/people')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.query({ withHidden: true, page: 2, size: 1 }); .query({ withHidden: true, page: 5, size: 1 });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toEqual({
hasNextPage: true, hasNextPage: true,
total: 3, total: 7,
hidden: 1, hidden: 1,
people: [expect.objectContaining({ name: 'visible_person' })], people: [expect.objectContaining({ name: 'visible_person' })],
}); });
@ -128,7 +191,7 @@ describe('/people', () => {
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ assets: 2 })); expect(body).toEqual(expect.objectContaining({ assets: 3 }));
}); });
}); });

View File

@ -244,7 +244,7 @@
"storage_template_migration_info": "The storage template will convert all extensions to lowercase. Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the <link>{job}</link>.", "storage_template_migration_info": "The storage template will convert all extensions to lowercase. Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the <link>{job}</link>.",
"storage_template_migration_job": "Storage Template Migration Job", "storage_template_migration_job": "Storage Template Migration Job",
"storage_template_more_details": "For more details about this feature, refer to the <template-link>Storage Template</template-link> and its <implications-link>implications</implications-link>", "storage_template_more_details": "For more details about this feature, refer to the <template-link>Storage Template</template-link> and its <implications-link>implications</implications-link>",
"storage_template_onboarding_description": "When enabled, this feature will auto-organize files based on a user-defined template. Due to stability issues the feature has been turned off by default. For more information, please see the <link>documentation</link>.", "storage_template_onboarding_description_v2": "When enabled, this feature will auto-organize files based on a user-defined template. For more information, please see the <link>documentation</link>.",
"storage_template_path_length": "Approximate path length limit: <b>{length, number}</b>/{limit, number}", "storage_template_path_length": "Approximate path length limit: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Storage Template", "storage_template_settings": "Storage Template",
"storage_template_settings_description": "Manage the folder structure and file name of the upload asset", "storage_template_settings_description": "Manage the folder structure and file name of the upload asset",

View File

@ -56,7 +56,7 @@ custom_lint:
allowed: allowed:
# required / wanted # required / wanted
- 'lib/infrastructure/repositories/album_media.repository.dart' - 'lib/infrastructure/repositories/album_media.repository.dart'
- 'lib/infrastructure/repositories/storage.repository.dart' - 'lib/infrastructure/repositories/{storage,asset_media}.repository.dart'
- 'lib/repositories/{album,asset,file}_media.repository.dart' - 'lib/repositories/{album,asset,file}_media.repository.dart'
# acceptable exceptions for the time being # acceptable exceptions for the time being
- lib/entities/asset.entity.dart # to provide local AssetEntity for now - lib/entities/asset.entity.dart # to provide local AssetEntity for now

View File

@ -83,7 +83,11 @@ open class NativeSyncApiImplBase(context: Context) {
continue continue
} }
val mediaType = c.getInt(mediaTypeColumn) val mediaType = when (c.getInt(mediaTypeColumn)) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2
else -> 0
}
val name = c.getString(nameColumn) val name = c.getString(nameColumn)
// Date taken is milliseconds since epoch, Date added is seconds since epoch // Date taken is milliseconds since epoch, Date added is seconds since epoch
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))

View File

@ -17,6 +17,7 @@ targets:
main: lib/infrastructure/repositories/db.repository.dart main: lib/infrastructure/repositories/db.repository.dart
generate_for: &drift_generate_for generate_for: &drift_generate_for
- lib/infrastructure/entities/*.dart - lib/infrastructure/entities/*.dart
- lib/infrastructure/entities/*.drift
- lib/infrastructure/repositories/db.repository.dart - lib/infrastructure/repositories/db.repository.dart
drift_dev:modular: drift_dev:modular:
enabled: true enabled: true

File diff suppressed because one or more lines are too long

View File

@ -36,6 +36,7 @@ class NativeSyncApiImpl: NativeSyncApi {
private let defaults: UserDefaults private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken" private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219
private let hashBufferSize = 2 * 1024 * 1024 private let hashBufferSize = 2 * 1024 * 1024
@ -91,9 +92,17 @@ class NativeSyncApiImpl: NativeSyncApi {
albumTypes.forEach { type in albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
collections.enumerateObjects { (album, _, _) in for i in 0..<collections.count {
let album = collections.object(at: i)
// Ignore recovered album
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
continue;
}
let options = PHFetchOptions() let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)] options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
options.includeHiddenAssets = false
let assets = PHAsset.fetchAssets(in: album, options: options) let assets = PHAsset.fetchAssets(in: album, options: options)
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
@ -149,7 +158,9 @@ class NativeSyncApiImpl: NativeSyncApi {
if (updated.isEmpty) { continue } if (updated.isEmpty) { continue }
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: nil) let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count { for i in 0..<result.count {
let asset = result.object(at: i) let asset = result.object(at: i)
@ -187,6 +198,7 @@ class NativeSyncApiImpl: NativeSyncApi {
collections.enumerateObjects { (album, _, _) in collections.enumerateObjects { (album, _, _) in
let options = PHFetchOptions() let options = PHFetchOptions()
options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id)) options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id))
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(in: album, options: options) let result = PHAsset.fetchAssets(in: album, options: options)
result.enumerateObjects { (asset, _, _) in result.enumerateObjects { (asset, _, _) in
albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier) albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier)
@ -203,7 +215,9 @@ class NativeSyncApiImpl: NativeSyncApi {
} }
var ids: [String] = [] var ids: [String] = []
let assets = PHAsset.fetchAssets(in: album, options: nil) let options = PHFetchOptions()
options.includeHiddenAssets = false
let assets = PHAsset.fetchAssets(in: album, options: options)
assets.enumerateObjects { (asset, _, _) in assets.enumerateObjects { (asset, _, _) in
ids.append(asset.localIdentifier) ids.append(asset.localIdentifier)
} }
@ -219,6 +233,7 @@ class NativeSyncApiImpl: NativeSyncApi {
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp)) let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
let options = PHFetchOptions() let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
options.includeHiddenAssets = false
let assets = PHAsset.fetchAssets(in: album, options: options) let assets = PHAsset.fetchAssets(in: album, options: options)
return Int64(assets.count) return Int64(assets.count)
} }
@ -230,6 +245,7 @@ class NativeSyncApiImpl: NativeSyncApi {
} }
let options = PHFetchOptions() let options = PHFetchOptions()
options.includeHiddenAssets = false
if(updatedTimeCond != nil) { if(updatedTimeCond != nil) {
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)

View File

@ -20,3 +20,8 @@ const String kSecuredPinCode = "secured_pin_code";
const kManualUploadGroup = 'manual_upload_group'; const kManualUploadGroup = 'manual_upload_group';
const kBackupGroup = 'backup_group'; const kBackupGroup = 'backup_group';
const kBackupLivePhotoGroup = 'backup_live_photo_group'; const kBackupLivePhotoGroup = 'backup_live_photo_group';
// Timeline constants
const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 256;
const int kTimelineAssetLoadOppositeSize = 64;

View File

@ -0,0 +1,10 @@
import 'dart:typed_data';
import 'dart:ui';
abstract interface class IAssetMediaRepository {
Future<Uint8List?> getThumbnail(
String id, {
int quality = 80,
Size size = const Size.square(256),
});
}

View File

@ -0,0 +1,27 @@
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
abstract interface class ITimelineRepository implements IDatabaseRepository {
Stream<List<Bucket>> watchMainBucket(
List<String> timelineUsers, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
});
Future<List<BaseAsset>> getMainBucketAssets(
List<String> timelineUsers, {
required int offset,
required int count,
});
Stream<List<Bucket>> watchLocalBucket(
String albumId, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
});
Future<List<BaseAsset>> getLocalBucketAssets(
String albumId, {
required int offset,
required int count,
});
}

View File

@ -11,6 +11,7 @@ enum AssetVisibility {
class Asset extends BaseAsset { class Asset extends BaseAsset {
final String id; final String id;
final String? localId; final String? localId;
final String? thumbHash;
final AssetVisibility visibility; final AssetVisibility visibility;
const Asset({ const Asset({
@ -25,9 +26,14 @@ class Asset extends BaseAsset {
super.height, super.height,
super.durationInSeconds, super.durationInSeconds,
super.isFavorite = false, super.isFavorite = false,
this.thumbHash,
this.visibility = AssetVisibility.timeline, this.visibility = AssetVisibility.timeline,
}); });
@override
AssetState get storage =>
localId == null ? AssetState.remote : AssetState.merged;
@override @override
String toString() { String toString() {
return '''Asset { return '''Asset {
@ -41,6 +47,7 @@ class Asset extends BaseAsset {
durationInSeconds: ${durationInSeconds ?? "<NA>"}, durationInSeconds: ${durationInSeconds ?? "<NA>"},
localId: ${localId ?? "<NA>"}, localId: ${localId ?? "<NA>"},
isFavorite: $isFavorite, isFavorite: $isFavorite,
thumbHash: ${thumbHash ?? "<NA>"},
visibility: $visibility, visibility: $visibility,
}'''; }''';
} }
@ -52,10 +59,15 @@ class Asset extends BaseAsset {
return super == other && return super == other &&
id == other.id && id == other.id &&
localId == other.localId && localId == other.localId &&
thumbHash == other.thumbHash &&
visibility == other.visibility; visibility == other.visibility;
} }
@override @override
int get hashCode => int get hashCode =>
super.hashCode ^ id.hashCode ^ localId.hashCode ^ visibility.hashCode; super.hashCode ^
id.hashCode ^
localId.hashCode ^
thumbHash.hashCode ^
visibility.hashCode;
} }

View File

@ -9,6 +9,12 @@ enum AssetType {
audio, audio,
} }
enum AssetState {
local,
remote,
merged,
}
sealed class BaseAsset { sealed class BaseAsset {
final String name; final String name;
final String? checksum; final String? checksum;
@ -32,6 +38,10 @@ sealed class BaseAsset {
this.isFavorite = false, this.isFavorite = false,
}); });
bool get isImage => type == AssetType.image;
bool get isVideo => type == AssetType.video;
AssetState get storage;
@override @override
String toString() { String toString() {
return '''BaseAsset { return '''BaseAsset {

View File

@ -18,6 +18,10 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false, super.isFavorite = false,
}); });
@override
AssetState get storage =>
remoteId == null ? AssetState.local : AssetState.merged;
@override @override
String toString() { String toString() {
return '''LocalAsset { return '''LocalAsset {

View File

@ -0,0 +1,12 @@
import 'package:immich_mobile/domain/models/store.model.dart';
enum Setting<T> {
tilesPerRow<int>(StoreKey.tilesPerRow, 4),
groupAssetsBy<int>(StoreKey.groupAssetsBy, 0),
showStorageIndicator<bool>(StoreKey.storageIndicator, true);
const Setting(this.storeKey, this.defaultValue);
final StoreKey<T> storeKey;
final T defaultValue;
}

View File

@ -0,0 +1,40 @@
enum GroupAssetsBy {
day,
month,
none;
}
enum HeaderType {
none,
month,
day,
monthAndDay;
}
class Bucket {
final int assetCount;
const Bucket({required this.assetCount});
@override
bool operator ==(covariant Bucket other) {
return assetCount == other.assetCount;
}
@override
int get hashCode => assetCount.hashCode;
}
class TimeBucket extends Bucket {
final DateTime date;
const TimeBucket({required this.date, required super.assetCount});
@override
bool operator ==(covariant TimeBucket other) {
return super == other && date == other.date;
}
@override
int get hashCode => super.hashCode ^ date.hashCode;
}

View File

@ -0,0 +1,19 @@
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
class SettingsService {
final StoreService _storeService;
const SettingsService({required StoreService storeService})
: _storeService = storeService;
T get<T>(Setting<T> setting) =>
_storeService.get(setting.storeKey, setting.defaultValue);
Future<void> set<T>(Setting<T> setting, T value) =>
_storeService.put(setting.storeKey, value);
Stream<T> watch<T>(Setting<T> setting) => _storeService
.watch(setting.storeKey)
.map((v) => v ?? setting.defaultValue);
}

View File

@ -0,0 +1,126 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/interfaces/timeline.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(
int index,
int count,
);
typedef TimelineBucketSource = Stream<List<Bucket>> Function();
class TimelineFactory {
final ITimelineRepository _timelineRepository;
final SettingsService _settingsService;
const TimelineFactory({
required ITimelineRepository timelineRepository,
required SettingsService settingsService,
}) : _timelineRepository = timelineRepository,
_settingsService = settingsService;
GroupAssetsBy get groupBy =>
GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)];
TimelineService main(List<String> timelineUsers) => TimelineService(
assetSource: (offset, count) => _timelineRepository
.getMainBucketAssets(timelineUsers, offset: offset, count: count),
bucketSource: () => _timelineRepository.watchMainBucket(
timelineUsers,
groupBy: groupBy,
),
);
TimelineService localAlbum({required String albumId}) => TimelineService(
assetSource: (offset, count) => _timelineRepository
.getLocalBucketAssets(albumId, offset: offset, count: count),
bucketSource: () =>
_timelineRepository.watchLocalBucket(albumId, groupBy: groupBy),
);
}
class TimelineService {
final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource;
TimelineService({
required TimelineAssetSource assetSource,
required TimelineBucketSource bucketSource,
}) : _assetSource = assetSource,
_bucketSource = bucketSource {
_bucketSubscription =
_bucketSource().listen((_) => unawaited(_reloadBucket()));
}
final AsyncMutex _mutex = AsyncMutex();
int _bufferOffset = 0;
List<BaseAsset> _buffer = [];
StreamSubscription? _bucketSubscription;
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
Future<void> _reloadBucket() => _mutex.run(() async {
_buffer = await _assetSource(_bufferOffset, _buffer.length);
});
Future<List<BaseAsset>> loadAssets(int index, int count) =>
_mutex.run(() => _loadAssets(index, count));
Future<List<BaseAsset>> _loadAssets(int index, int count) async {
if (hasRange(index, count)) {
return getAssets(index, count);
}
// if the requested offset is greater than the cached offset, the user scrolls forward "down"
final bool forward = _bufferOffset < index;
// make sure to load a meaningful amount of data (and not only the requested slice)
// otherwise, each call to [loadAssets] would result in DB call trashing performance
// fills small requests to [kTimelineAssetLoadBatchSize], adds some legroom into the opposite scroll direction for large requests
final len = math.max(
kTimelineAssetLoadBatchSize,
count + kTimelineAssetLoadOppositeSize,
);
// when scrolling forward, start shortly before the requested offset
// when scrolling backward, end shortly after the requested offset to guard against the user scrolling
// in the other direction a tiny bit resulting in another required load from the DB
final start = math.max(
0,
forward
? index - kTimelineAssetLoadOppositeSize
: (len > kTimelineAssetLoadBatchSize ? index : index + count - len),
);
final assets = await _assetSource(start, len);
_buffer = assets;
_bufferOffset = start;
return getAssets(index, count);
}
bool hasRange(int index, int count) =>
index >= _bufferOffset && index + count <= _bufferOffset + _buffer.length;
List<BaseAsset> getAssets(int index, int count) {
if (!hasRange(index, count)) {
throw RangeError('TimelineService::getAssets Index out of range');
}
int start = index - _bufferOffset;
return _buffer.slice(start, start + count);
}
Future<void> dispose() async {
await _bucketSubscription?.cancel();
_bucketSubscription = null;
_buffer.clear();
_bufferOffset = 0;
}
}

View File

@ -23,9 +23,7 @@ extension LogOnError<T> on AsyncValue<T> {
if (!skip) { if (!skip) {
return onLoading?.call() ?? return onLoading?.call() ??
const Center( const Center(child: ImmichLoadingIndicator());
child: ImmichLoadingIndicator(),
);
} }
} }

View File

@ -0,0 +1,50 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:intl/message_format.dart';
import 'package:flutter/material.dart';
extension StringTranslateExtension on String {
String t({BuildContext? context, Map<String, Object>? args}) {
return _translateHelper(context, this, args);
}
}
extension TextTranslateExtension on Text {
Text t({BuildContext? context, Map<String, Object>? args}) {
return Text(
_translateHelper(context, data ?? '', args),
key: key,
style: style,
strutStyle: strutStyle,
textAlign: textAlign,
textDirection: textDirection,
locale: locale,
softWrap: softWrap,
overflow: overflow,
textScaler: textScaler,
maxLines: maxLines,
semanticsLabel: semanticsLabel,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
);
}
}
String _translateHelper(
BuildContext? context,
String key, [
Map<String, Object>? args,
]) {
if (key.isEmpty) {
return '';
}
try {
final translatedMessage = key.tr(context: context);
return args != null
? MessageFormat(translatedMessage, locale: Intl.defaultLocale ?? 'en')
.format(args)
: translatedMessage;
} catch (e) {
debugPrint('Translation failed for key "$key". Error: $e');
return key;
}
}

View File

@ -1,4 +1,6 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@ -15,3 +17,16 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
extension LocalAssetEntityDataDomainEx on LocalAssetEntityData {
LocalAsset toDto() => LocalAsset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
);
}

View File

@ -0,0 +1,81 @@
import 'remote_asset.entity.dart';
import 'local_asset.entity.dart';
mergedAsset: SELECT * FROM
(
SELECT
rae.id as remote_id,
lae.id as local_id,
rae.name,
rae."type",
rae.created_at,
rae.updated_at,
rae.width,
rae.height,
rae.duration_in_seconds,
rae.is_favorite,
rae.thumb_hash,
rae.checksum,
rae.owner_id
FROM
remote_asset_entity rae
LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum
WHERE
rae.visibility = 0 AND rae.owner_id in ?
UNION ALL
SELECT
NULL as remote_id,
lae.id as local_id,
lae.name,
lae."type",
lae.created_at,
lae.updated_at,
lae.width,
lae.height,
lae.duration_in_seconds,
lae.is_favorite,
NULL as thumb_hash,
lae.checksum,
NULL as owner_id
FROM
local_asset_entity lae
LEFT JOIN
remote_asset_entity rae ON rae.checksum = lae.checksum
WHERE
rae.id IS NULL
)
ORDER BY created_at DESC
LIMIT $limit;
mergedBucket(:group_by AS INTEGER):
SELECT
COUNT(*) as asset_count,
CASE
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at) -- day
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at) -- month
END AS bucket_date
FROM
(
SELECT
rae.name,
rae.created_at
FROM
remote_asset_entity rae
LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum
WHERE
rae.visibility = 0 AND rae.owner_id in ?
UNION ALL
SELECT
lae.name,
lae.created_at
FROM
local_asset_entity lae
LEFT JOIN
remote_asset_entity rae ON rae.checksum = lae.checksum
WHERE
rae.id IS NULL
)
GROUP BY bucket_date
ORDER BY bucket_date DESC;

View File

@ -0,0 +1,114 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:drift/internal/modular.dart' as i1;
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4;
class MergedAssetDrift extends i1.ModularAccessor {
MergedAssetDrift(i0.GeneratedDatabase db) : super(db);
i0.Selectable<MergedAssetResult> mergedAsset(List<String> var1,
{required i0.Limit limit}) {
var $arrayStartIndex = 1;
final expandedvar1 = $expandVar($arrayStartIndex, var1.length);
$arrayStartIndex += var1.length;
final generatedlimit = $write(limit, startIndex: $arrayStartIndex);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in var1) i0.Variable<String>($),
...generatedlimit.introducedVariables
],
readsFrom: {
remoteAssetEntity,
localAssetEntity,
...generatedlimit.watchedTables,
}).map((i0.QueryRow row) => MergedAssetResult(
remoteId: row.readNullable<String>('remote_id'),
localId: row.readNullable<String>('local_id'),
name: row.read<String>('name'),
type: i3.$RemoteAssetEntityTable.$convertertype
.fromSql(row.read<int>('type')),
createdAt: row.read<DateTime>('created_at'),
updatedAt: row.read<DateTime>('updated_at'),
width: row.readNullable<int>('width'),
height: row.readNullable<int>('height'),
durationInSeconds: row.readNullable<int>('duration_in_seconds'),
isFavorite: row.read<bool>('is_favorite'),
thumbHash: row.readNullable<String>('thumb_hash'),
checksum: row.readNullable<String>('checksum'),
ownerId: row.readNullable<String>('owner_id'),
));
}
i0.Selectable<MergedBucketResult> mergedBucket(List<String> var2,
{required int groupBy}) {
var $arrayStartIndex = 2;
final expandedvar2 = $expandVar($arrayStartIndex, var2.length);
$arrayStartIndex += var2.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at) WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at) END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in var2) i0.Variable<String>($)
],
readsFrom: {
remoteAssetEntity,
localAssetEntity,
}).map((i0.QueryRow row) => MergedBucketResult(
assetCount: row.read<int>('asset_count'),
bucketDate: row.read<String>('bucket_date'),
));
}
i3.$RemoteAssetEntityTable get remoteAssetEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity');
i4.$LocalAssetEntityTable get localAssetEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i4.$LocalAssetEntityTable>('local_asset_entity');
}
class MergedAssetResult {
final String? remoteId;
final String? localId;
final String name;
final i2.AssetType type;
final DateTime createdAt;
final DateTime updatedAt;
final int? width;
final int? height;
final int? durationInSeconds;
final bool isFavorite;
final String? thumbHash;
final String? checksum;
final String? ownerId;
MergedAssetResult({
this.remoteId,
this.localId,
required this.name,
required this.type,
required this.createdAt,
required this.updatedAt,
this.width,
this.height,
this.durationInSeconds,
required this.isFavorite,
this.thumbHash,
this.checksum,
this.ownerId,
});
}
class MergedBucketResult {
final int assetCount;
final String bucketDate;
MergedBucketResult({
required this.assetCount,
required this.bucketDate,
});
}

View File

@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
columns: {#checksum, #ownerId}, columns: {#checksum, #ownerId},
unique: true, unique: true,
) )
@TableIndex(name: 'idx_remote_asset_checksum', columns: {#checksum})
class RemoteAssetEntity extends Table class RemoteAssetEntity extends Table
with DriftDefaultsMixin, AssetEntityMixin { with DriftDefaultsMixin, AssetEntityMixin {
const RemoteAssetEntity(); const RemoteAssetEntity();

View File

@ -1178,3 +1178,6 @@ class RemoteAssetEntityCompanion
.toString(); .toString();
} }
} }
i0.Index get idxRemoteAssetChecksum => i0.Index('idx_remote_asset_checksum',
'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)');

View File

@ -0,0 +1,28 @@
import 'dart:typed_data';
import 'dart:ui';
import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetMediaRepository implements IAssetMediaRepository {
const AssetMediaRepository();
@override
Future<Uint8List?> getThumbnail(
String id, {
int quality = 80,
Size size = const Size.square(256),
}) =>
AssetEntity(
id: id,
// The below fields are not used in thumbnailDataWithSize but are required
// to create an AssetEntity instance. It is faster to create a dummy AssetEntity
// instance than to fetch the asset from the device first.
typeInt: AssetType.image.index,
width: size.width.toInt(),
height: size.height.toInt(),
).thumbnailDataWithSize(
ThumbnailSize(size.width.toInt(), size.height.toInt()),
quality: quality,
);
}

View File

@ -41,6 +41,9 @@ class IsarDatabaseRepository implements IDatabaseRepository {
RemoteAssetEntity, RemoteAssetEntity,
RemoteExifEntity, RemoteExifEntity,
], ],
include: {
'package:immich_mobile/infrastructure/entities/merged_asset.drift',
},
) )
class Drift extends $Drift implements IDatabaseRepository { class Drift extends $Drift implements IDatabaseRepository {
Drift([QueryExecutor? executor]) Drift([QueryExecutor? executor])

View File

@ -3,59 +3,72 @@
import 'package:drift/drift.dart' as i0; import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
as i1; as i1;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i2;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i2;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i7; as i7;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i8; as i8;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i9;
import 'package:drift/internal/modular.dart' as i10;
abstract class $Drift extends i0.GeneratedDatabase { abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e); $Drift(i0.QueryExecutor e) : super(e);
$DriftManager get managers => $DriftManager(this); $DriftManager get managers => $DriftManager(this);
late final i1.$UserEntityTable userEntity = i1.$UserEntityTable(this); late final i1.$UserEntityTable userEntity = i1.$UserEntityTable(this);
late final i2.$UserMetadataEntityTable userMetadataEntity = late final i2.$RemoteAssetEntityTable remoteAssetEntity =
i2.$UserMetadataEntityTable(this); i2.$RemoteAssetEntityTable(this);
late final i3.$PartnerEntityTable partnerEntity = late final i3.$LocalAssetEntityTable localAssetEntity =
i3.$PartnerEntityTable(this); i3.$LocalAssetEntityTable(this);
late final i4.$LocalAlbumEntityTable localAlbumEntity = late final i4.$UserMetadataEntityTable userMetadataEntity =
i4.$LocalAlbumEntityTable(this); i4.$UserMetadataEntityTable(this);
late final i5.$LocalAssetEntityTable localAssetEntity = late final i5.$PartnerEntityTable partnerEntity =
i5.$LocalAssetEntityTable(this); i5.$PartnerEntityTable(this);
late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity = late final i6.$LocalAlbumEntityTable localAlbumEntity =
i6.$LocalAlbumAssetEntityTable(this); i6.$LocalAlbumEntityTable(this);
late final i7.$RemoteAssetEntityTable remoteAssetEntity = late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
i7.$RemoteAssetEntityTable(this); i7.$LocalAlbumAssetEntityTable(this);
late final i8.$RemoteExifEntityTable remoteExifEntity = late final i8.$RemoteExifEntityTable remoteExifEntity =
i8.$RemoteExifEntityTable(this); i8.$RemoteExifEntityTable(this);
i9.MergedAssetDrift get mergedAssetDrift => i10.ReadDatabaseContainer(this)
.accessor<i9.MergedAssetDrift>(i9.MergedAssetDrift.new);
@override @override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables => Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>(); allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@override @override
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [ List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
userEntity, userEntity,
remoteAssetEntity,
localAssetEntity,
i3.idxLocalAssetChecksum,
i2.uQRemoteAssetOwnerChecksum,
i2.idxRemoteAssetChecksum,
userMetadataEntity, userMetadataEntity,
partnerEntity, partnerEntity,
localAlbumEntity, localAlbumEntity,
localAssetEntity,
localAlbumAssetEntity, localAlbumAssetEntity,
remoteAssetEntity, remoteExifEntity
remoteExifEntity,
i5.idxLocalAssetChecksum,
i7.uQRemoteAssetOwnerChecksum
]; ];
@override @override
i0.StreamQueryUpdateRules get streamUpdateRules => i0.StreamQueryUpdateRules get streamUpdateRules =>
const i0.StreamQueryUpdateRules( const i0.StreamQueryUpdateRules(
[ [
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation( i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity', on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete), limitUpdateKind: i0.UpdateKind.delete),
@ -94,13 +107,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
kind: i0.UpdateKind.delete), kind: i0.UpdateKind.delete),
], ],
), ),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation( i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('remote_asset_entity', on: i0.TableUpdateQuery.onTableName('remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete), limitUpdateKind: i0.UpdateKind.delete),
@ -120,18 +126,18 @@ class $DriftManager {
$DriftManager(this._db); $DriftManager(this._db);
i1.$$UserEntityTableTableManager get userEntity => i1.$$UserEntityTableTableManager get userEntity =>
i1.$$UserEntityTableTableManager(_db, _db.userEntity); i1.$$UserEntityTableTableManager(_db, _db.userEntity);
i2.$$UserMetadataEntityTableTableManager get userMetadataEntity => i2.$$RemoteAssetEntityTableTableManager get remoteAssetEntity =>
i2.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); i2.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity);
i3.$$PartnerEntityTableTableManager get partnerEntity => i3.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i3.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); i3.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i4.$$LocalAlbumEntityTableTableManager get localAlbumEntity => i4.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i4.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity); i4.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i5.$$LocalAssetEntityTableTableManager get localAssetEntity => i5.$$PartnerEntityTableTableManager get partnerEntity =>
i5.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity); i5.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6 i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity); .$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i7.$$RemoteAssetEntityTableTableManager get remoteAssetEntity =>
i7.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity);
i8.$$RemoteExifEntityTableTableManager get remoteExifEntity => i8.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity); i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
} }

View File

@ -0,0 +1,180 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/interfaces/timeline.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:stream_transform/stream_transform.dart';
class DriftTimelineRepository extends DriftDatabaseRepository
implements ITimelineRepository {
final Drift _db;
const DriftTimelineRepository(super._db) : _db = _db;
List<Bucket> _generateBuckets(int count) {
final numBuckets = (count / kTimelineNoneSegmentSize).floor();
final buckets = List.generate(
numBuckets,
(_) => const Bucket(assetCount: kTimelineNoneSegmentSize),
);
if (count % kTimelineNoneSegmentSize != 0) {
buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize));
}
return buckets;
}
@override
Stream<List<Bucket>> watchMainBucket(
List<String> userIds, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
if (groupBy == GroupAssetsBy.none) {
throw UnsupportedError(
"GroupAssetsBy.none is not supported for watchMainBucket",
);
}
return _db.mergedAssetDrift
.mergedBucket(userIds, groupBy: groupBy.index)
.map((row) {
final date = row.bucketDate.dateFmt(groupBy);
return TimeBucket(date: date, assetCount: row.assetCount);
})
.watch()
.throttle(const Duration(seconds: 3), trailing: true);
}
@override
Future<List<BaseAsset>> getMainBucketAssets(
List<String> userIds, {
required int offset,
required int count,
}) {
return _db.mergedAssetDrift
.mergedAsset(userIds, limit: Limit(count, offset))
.map(
(row) => row.remoteId != null
? Asset(
id: row.remoteId!,
localId: row.localId,
name: row.name,
checksum: row.checksum,
type: row.type,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
thumbHash: row.thumbHash,
width: row.width,
height: row.height,
isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds,
)
: LocalAsset(
id: row.localId!,
remoteId: row.remoteId,
name: row.name,
checksum: row.checksum,
type: row.type,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
width: row.width,
height: row.height,
isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds,
),
)
.get();
}
@override
Stream<List<Bucket>> watchLocalBucket(
String albumId, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
if (groupBy == GroupAssetsBy.none) {
return _db.localAlbumAssetEntity
.count(where: (row) => row.albumId.equals(albumId))
.map(_generateBuckets)
.watchSingle();
}
final assetCountExp = _db.localAssetEntity.id.count();
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy);
final query = _db.localAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..join([
innerJoin(
_db.localAlbumAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
),
])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
return query.map((row) {
final timeline = row.read(dateExp)!.dateFmt(groupBy);
final assetCount = row.read(assetCountExp)!;
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
}
@override
Future<List<BaseAsset>> getLocalBucketAssets(
String albumId, {
required int offset,
required int count,
}) {
final query = _db.localAssetEntity.select().join(
[
innerJoin(
_db.localAlbumAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
),
],
)
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..limit(count, offset: offset);
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
}
}
extension on Expression<DateTime> {
Expression<String> dateFmt(GroupAssetsBy groupBy) {
// DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting
// to create the correct time bucket
final localTimeExp = modify(const DateTimeModifier.localTime());
return switch (groupBy) {
GroupAssetsBy.day => localTimeExp.date,
GroupAssetsBy.month => localTimeExp.strftime("%Y-%m"),
GroupAssetsBy.none => throw ArgumentError(
"GroupAssetsBy.none is not supported for date formatting",
),
};
}
}
extension on String {
DateTime dateFmt(GroupAssetsBy groupBy) {
final format = switch (groupBy) {
GroupAssetsBy.day => "y-M-d",
GroupAssetsBy.month => "y-M",
GroupAssetsBy.none => throw ArgumentError(
"GroupAssetsBy.none is not supported for date formatting",
),
};
try {
return DateFormat(format).parse(this);
} catch (e) {
throw FormatException("Invalid date format: $this", e);
}
}
}

View File

@ -8,13 +8,13 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/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/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.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';
import 'package:immich_mobile/utils/translation.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@ -230,11 +230,17 @@ class AlbumsPage extends HookConsumerWidget {
), ),
subtitle: sorted[index].ownerId != null subtitle: sorted[index].ownerId != null
? Text( ? Text(
'${t('items_count', { '${'items_count'.t(
context: context,
args: {
'count': sorted[index].assetCount, 'count': sorted[index].assetCount,
})} ${sorted[index].ownerId != userId ? t('shared_by_user', { },
)} ${sorted[index].ownerId != userId ? 'shared_by_user'.t(
context: context,
args: {
'user': sorted[index].ownerName!, 'user': sorted[index].ownerName!,
}) : 'owned'.tr()}', },
) : 'owned'.t(context: context)}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: style:
context.textTheme.bodyMedium?.copyWith( context.textTheme.bodyMedium?.copyWith(

View File

@ -2,9 +2,9 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.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/utils/translation.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@ -46,7 +46,10 @@ class LocalAlbumsPage extends HookConsumerWidget {
), ),
), ),
subtitle: Text( subtitle: Text(
t('items_count', {'count': albums[index].assetCount}), 'items_count'.t(
context: context,
args: {'count': albums[index].assetCount},
),
style: context.textTheme.bodyMedium?.copyWith( style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceSecondary, color: context.colorScheme.onSurfaceSecondary,
), ),

View File

@ -48,6 +48,10 @@ final _features = [
), ),
_Feature( _Feature(
name: 'Clear Local Data', name: 'Clear Local Data',
style: const TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
icon: Icons.delete_forever_rounded, icon: Icons.delete_forever_rounded,
onTap: (_, ref) async { onTap: (_, ref) async {
final db = ref.read(driftProvider); final db = ref.read(driftProvider);
@ -58,6 +62,10 @@ final _features = [
), ),
_Feature( _Feature(
name: 'Clear Remote Data', name: 'Clear Remote Data',
style: const TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
icon: Icons.delete_sweep_rounded, icon: Icons.delete_sweep_rounded,
onTap: (_, ref) async { onTap: (_, ref) async {
final db = ref.read(driftProvider); final db = ref.read(driftProvider);
@ -67,17 +75,29 @@ final _features = [
), ),
_Feature( _Feature(
name: 'Local Media Summary', name: 'Local Media Summary',
style: const TextStyle(
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
icon: Icons.table_chart_rounded, icon: Icons.table_chart_rounded,
onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()), onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
), ),
_Feature( _Feature(
name: 'Remote Media Summary', name: 'Remote Media Summary',
style: const TextStyle(
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
icon: Icons.summarize_rounded, icon: Icons.summarize_rounded,
onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()), onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
), ),
_Feature( _Feature(
name: 'Reset Sqlite', name: 'Reset Sqlite',
icon: Icons.table_view_rounded, icon: Icons.table_view_rounded,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
onTap: (_, ref) async { onTap: (_, ref) async {
final drift = ref.read(driftProvider); final drift = ref.read(driftProvider);
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
@ -88,6 +108,11 @@ final _features = [
} }
}, },
), ),
_Feature(
name: 'Main Timeline',
icon: Icons.timeline_rounded,
onTap: (ctx, _) => ctx.pushRoute(const MainTimelineRoute()),
),
]; ];
@RoutePage() @RoutePage()
@ -110,7 +135,10 @@ class FeatInDevPage extends StatelessWidget {
final feat = _features[index]; final feat = _features[index];
return Consumer( return Consumer(
builder: (ctx, ref, _) => ListTile( builder: (ctx, ref, _) => ListTile(
title: Text(feat.name), title: Text(
feat.name,
style: feat.style,
),
trailing: Icon(feat.icon), trailing: Icon(feat.icon),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onTap: () => unawaited(feat.onTap(ctx, ref)), onTap: () => unawaited(feat.onTap(ctx, ref)),
@ -133,10 +161,13 @@ class _Feature {
required this.name, required this.name,
required this.icon, required this.icon,
required this.onTap, required this.onTap,
// ignore: unused_element_parameter
this.style,
}); });
final String name; final String name;
final IconData icon; final IconData icon;
final TextStyle? style;
final Future<void> Function(BuildContext, WidgetRef _) onTap; final Future<void> Function(BuildContext, WidgetRef _) onTap;
} }

View File

@ -0,0 +1,31 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@RoutePage()
class LocalTimelinePage extends StatelessWidget {
final String albumId;
const LocalTimelinePage({super.key, required this.albumId});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith(
(ref) {
final timelineService =
ref.watch(timelineFactoryProvider).localAlbum(albumId: albumId);
ref.onDispose(() => unawaited(timelineService.dispose()));
return timelineService;
},
),
],
child: const Timeline(),
);
}
}

View File

@ -0,0 +1,31 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class MainTimelinePage extends StatelessWidget {
const MainTimelinePage({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith(
(ref) {
final timelineService = ref
.watch(timelineFactoryProvider)
.main(ref.watch(timelineUsersIdsProvider));
ref.onDispose(() => unawaited(timelineService.dispose()));
return timelineService;
},
),
],
child: const Timeline(),
);
}
}

View File

@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.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/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class _Stat { class _Stat {
const _Stat({required this.name, required this.load}); const _Stat({required this.name, required this.load});
@ -16,9 +17,16 @@ class _Stat {
class _Summary extends StatelessWidget { class _Summary extends StatelessWidget {
final String name; final String name;
final Widget? leading;
final Future<int> countFuture; final Future<int> countFuture;
final void Function()? onTap;
const _Summary({required this.name, required this.countFuture}); const _Summary({
required this.name,
required this.countFuture,
this.leading,
this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -34,7 +42,12 @@ class _Summary extends StatelessWidget {
} else { } else {
subtitle = Text('${snapshot.data ?? 0}'); subtitle = Text('${snapshot.data ?? 0}');
} }
return ListTile(title: Text(name), trailing: subtitle); return ListTile(
leading: leading,
title: Text(name),
trailing: subtitle,
onTap: onTap,
);
}, },
); );
} }
@ -105,8 +118,12 @@ class LocalMediaSummaryPage extends StatelessWidget {
.filter((f) => f.albumId.id.equals(album.id)) .filter((f) => f.albumId.id.equals(album.id))
.count(); .count();
return _Summary( return _Summary(
leading: const Icon(Icons.photo_album_rounded),
name: album.name, name: album.name,
countFuture: countFuture, countFuture: countFuture,
onTap: () => context.router.push(
LocalTimelineRoute(albumId: album.id),
),
); );
}, },
itemCount: albums.length, itemCount: albums.length,

View File

@ -0,0 +1,96 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final IAssetMediaRepository _assetMediaRepository =
const AssetMediaRepository();
final CacheManager? cacheManager;
final LocalAsset asset;
final double height;
final double width;
LocalThumbProvider({
required this.asset,
this.height = kTimelineFixedTileExtent,
this.width = kTimelineFixedTileExtent,
this.cacheManager,
});
@override
Future<LocalThumbProvider> obtainKey(
ImageConfiguration configuration,
) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
LocalThumbProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode),
scale: 1.0,
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<LocalAsset>('Asset', key.asset),
],
);
}
Future<Codec> _codec(
LocalThumbProvider key,
CacheManager cache,
ImageDecoderCallback decode,
) async {
final cacheKey = '${key.asset.id}-${key.asset.updatedAt}-${width}x$height';
final fileFromCache = await cache.getFileFromCache(cacheKey);
if (fileFromCache != null) {
try {
final buffer =
await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
return await decode(buffer);
} catch (_) {}
}
final thumbnailBytes = await _assetMediaRepository.getThumbnail(
key.asset.id,
size: Size(key.width, key.height),
);
if (thumbnailBytes == null) {
PaintingBinding.instance.imageCache.evict(key);
throw StateError(
"Loading thumb for local photo ${key.asset.name} failed",
);
}
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
unawaited(cache.putFile(cacheKey, thumbnailBytes));
return decode(buffer);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is LocalThumbProvider) {
return asset.id == other.asset.id &&
asset.updatedAt == other.asset.updatedAt;
}
return false;
}
@override
int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode;
}

View File

@ -0,0 +1,80 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
final String assetId;
final double height;
final double width;
final CacheManager? cacheManager;
RemoteThumbProvider({
required this.assetId,
this.height = kTimelineFixedTileExtent,
this.width = kTimelineFixedTileExtent,
this.cacheManager,
});
@override
Future<RemoteThumbProvider> obtainKey(
ImageConfiguration configuration,
) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
RemoteThumbProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
final chunkController = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode, chunkController),
scale: 1.0,
chunkEvents: chunkController.stream,
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
);
}
Future<Codec> _codec(
RemoteThumbProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkController,
) async {
final preview = getThumbnailUrlForRemoteId(
key.assetId,
);
return ImageLoader.loadImageFromCache(
preview,
cache: cache,
decode: decode,
chunkEvents: chunkController,
).whenComplete(chunkController.close);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}

View File

@ -0,0 +1,50 @@
import 'dart:convert' hide Codec;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:thumbhash/thumbhash.dart';
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
final String thumbHash;
ThumbHashProvider({
required this.thumbHash,
});
@override
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
ThumbHashProvider key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(
codec: _loadCodec(key, decode),
scale: 1.0,
);
}
Future<Codec> _loadCodec(
ThumbHashProvider key,
ImageDecoderCallback decode,
) async {
final image = thumbHashToRGBA(base64Decode(key.thumbHash));
return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image)));
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ThumbHashProvider) {
return thumbHash == other.thumbHash;
}
return false;
}
@override
int get hashCode => thumbHash.hashCode;
}

View File

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/local_thumb_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_thumb_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
import 'package:logging/logging.dart';
import 'package:octo_image/octo_image.dart';
class Thumbnail extends StatelessWidget {
const Thumbnail({
required this.asset,
this.size = const Size.square(256),
this.fit = BoxFit.cover,
super.key,
});
final BaseAsset asset;
final Size size;
final BoxFit fit;
static ImageProvider imageProvider({
required BaseAsset asset,
Size size = const Size.square(256),
}) {
if (asset is LocalAsset) {
return LocalThumbProvider(
asset: asset,
height: size.height,
width: size.width,
);
}
if (asset is Asset) {
return RemoteThumbProvider(
assetId: asset.id,
height: size.height,
width: size.width,
);
}
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
@override
Widget build(BuildContext context) {
final thumbHash = asset is Asset ? (asset as Asset).thumbHash : null;
final provider = imageProvider(asset: asset, size: size);
return OctoImage.fromSet(
image: provider,
octoSet: OctoSet(
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit),
errorBuilder: _blurHashErrorBuilder(
thumbHash,
provider: provider,
fit: fit,
asset: asset,
),
),
fadeOutDuration: const Duration(milliseconds: 100),
fadeInDuration: Duration.zero,
width: size.width,
height: size.height,
fit: fit,
placeholderFadeInDuration: Duration.zero,
);
}
}
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(
String? thumbHash, {
BoxFit? fit,
}) {
return (context) => thumbHash == null
? const ThumbnailPlaceholder()
: FadeInPlaceholderImage(
placeholder: const ThumbnailPlaceholder(),
image: ThumbHashProvider(thumbHash: thumbHash),
fit: fit ?? BoxFit.cover,
);
}
OctoErrorBuilder _blurHashErrorBuilder(
String? blurhash, {
BaseAsset? asset,
ImageProvider? provider,
BoxFit? fit,
}) =>
(context, e, s) {
Logger("ImThumbnail")
.warning("Error loading thumbnail for ${asset?.name}", e, s);
provider?.evict();
return Stack(
alignment: Alignment.center,
children: [
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
const Opacity(
opacity: 0.75,
child: Icon(Icons.error_outline_rounded),
),
],
);
};

View File

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
class ThumbnailTile extends StatelessWidget {
const ThumbnailTile(
this.asset, {
this.size = const Size.square(256),
this.fit = BoxFit.cover,
this.showStorageIndicator = true,
super.key,
});
final BaseAsset asset;
final Size size;
final BoxFit fit;
final bool showStorageIndicator;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(child: Thumbnail(asset: asset, fit: fit, size: size)),
if (asset.isVideo)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.only(right: 10.0, top: 6.0),
child: _VideoIndicator(asset.durationInSeconds ?? 0),
),
),
if (showStorageIndicator)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 10.0, bottom: 6.0),
child: _TileOverlayIcon(
switch (asset.storage) {
AssetState.local => Icons.cloud_off_outlined,
AssetState.remote => Icons.cloud_outlined,
AssetState.merged => Icons.cloud_done_outlined,
},
),
),
),
if (asset.isFavorite)
const Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: EdgeInsets.only(left: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.favorite_rounded),
),
),
],
);
}
}
class _VideoIndicator extends StatelessWidget {
final int durationInSeconds;
const _VideoIndicator(this.durationInSeconds);
String _formatDuration(int durationInSec) {
final int hours = durationInSec ~/ 3600;
final int minutes = (durationInSec % 3600) ~/ 60;
final int seconds = durationInSec % 60;
final String minutesPadded = minutes.toString().padLeft(2, '0');
final String secondsPadded = seconds.toString().padLeft(2, '0');
if (hours > 0) {
return "$hours:$minutesPadded:$secondsPadded"; // H:MM:SS
} else {
return "$minutesPadded:$secondsPadded"; // MM:SS
}
}
@override
Widget build(BuildContext context) {
return Row(
spacing: 3,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
// CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatDuration(durationInSeconds),
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
blurRadius: 5.0,
color: Colors.black.withValues(alpha: 0.6),
),
],
),
),
const _TileOverlayIcon(Icons.play_circle_outline_rounded),
],
);
}
}
class _TileOverlayIcon extends StatelessWidget {
final IconData icon;
const _TileOverlayIcon(this.icon);
@override
Widget build(BuildContext context) {
return Icon(
icon,
color: Colors.white,
size: 16,
shadows: [
Shadow(
blurRadius: 5.0,
color: Colors.black.withValues(alpha: 0.6),
offset: const Offset(0.0, 0.0),
),
],
);
}
}

View File

@ -0,0 +1,7 @@
const double kTimelineHeaderExtent = 80.0;
const double kTimelineFixedTileExtent = 256;
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);

View File

@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class FixedTimelineRow extends MultiChildRenderObjectWidget {
final double dimension;
final double spacing;
final TextDirection textDirection;
const FixedTimelineRow({
super.key,
required this.dimension,
required this.spacing,
required this.textDirection,
required super.children,
});
@override
RenderObject createRenderObject(BuildContext context) {
return RenderFixedRow(
dimension: dimension,
spacing: spacing,
textDirection: textDirection,
);
}
@override
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
renderObject.dimension = dimension;
renderObject.spacing = spacing;
renderObject.textDirection = textDirection;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
}
class _RowParentData extends ContainerBoxParentData<RenderBox> {}
class RenderFixedRow extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _RowParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({
List<RenderBox>? children,
required double dimension,
required double spacing,
required TextDirection textDirection,
}) : _dimension = dimension,
_spacing = spacing,
_textDirection = textDirection {
addAll(children);
}
double get dimension => _dimension;
double _dimension;
set dimension(double value) {
if (_dimension == value) return;
_dimension = value;
markNeedsLayout();
}
double get spacing => _spacing;
double _spacing;
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
TextDirection get textDirection => _textDirection;
TextDirection _textDirection;
set textDirection(TextDirection value) {
if (_textDirection == value) return;
_textDirection = value;
markNeedsLayout();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _RowParentData) {
child.parentData = _RowParentData();
}
}
double get intrinsicWidth =>
dimension * childCount + spacing * (childCount - 1);
@override
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
@override
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
@override
double computeMinIntrinsicHeight(double width) => dimension;
@override
double computeMaxIntrinsicHeight(double width) => dimension;
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
return defaultComputeDistanceToHighestActualBaseline(baseline);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
@override
void performLayout() {
RenderBox? child = firstChild;
if (child == null) {
size = constraints.smallest;
return;
}
// Use the entire width of the parent for the row.
size = Size(constraints.maxWidth, dimension);
// Each tile is forced to be dimension x dimension.
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
final flipMainAxis = textDirection == TextDirection.rtl;
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
// Layout each child horizontally.
while (child != null) {
child.layout(childConstraints, parentUsesSize: false);
final childParentData = child.parentData! as _RowParentData;
childParentData.offset = offset;
offset += Offset(dx, 0);
child = childParentData.nextSibling;
}
}
}

View File

@ -0,0 +1,132 @@
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class FixedSegment extends Segment {
final double tileHeight;
final int columnCount;
final double mainAxisExtend;
const FixedSegment({
required super.firstIndex,
required super.lastIndex,
required super.startOffset,
required super.endOffset,
required super.firstAssetIndex,
required super.bucket,
required this.tileHeight,
required this.columnCount,
required super.headerExtent,
required super.spacing,
required super.header,
}) : assert(tileHeight != 0),
mainAxisExtend = tileHeight + spacing;
@override
double indexToLayoutOffset(int index) {
index -= gridIndex;
if (index < 0) {
return startOffset;
}
return gridOffset + (mainAxisExtend * index);
}
@override
int getMinChildIndexForScrollOffset(double scrollOffset) {
scrollOffset -= gridOffset;
if (!scrollOffset.isFinite || scrollOffset < 0) {
return firstIndex;
}
final rowsAbove = (scrollOffset / mainAxisExtend).floor();
return gridIndex + rowsAbove;
}
@override
int getMaxChildIndexForScrollOffset(double scrollOffset) {
scrollOffset -= gridOffset;
if (!scrollOffset.isFinite || scrollOffset < 0) {
return firstIndex;
}
final firstRowBelow = (scrollOffset / mainAxisExtend).ceil();
return gridIndex + firstRowBelow - 1;
}
@override
Widget builder(BuildContext context, int index) {
if (index == firstIndex) {
return TimelineHeader(
bucket: bucket,
header: header,
height: headerExtent,
);
}
final rowIndexInSegment = index - (firstIndex + 1);
final assetIndex = rowIndexInSegment * columnCount;
final assetCount = bucket.assetCount;
final numberOfAssets = math.min(columnCount, assetCount - assetIndex);
return _buildRow(firstAssetIndex + assetIndex, numberOfAssets);
}
Widget _buildRow(int assetIndex, int count) => Consumer(
builder: (ctx, ref, _) {
final isScrubbing =
ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
// Timeline is being scrubbed, show placeholders
if (isScrubbing) {
return SegmentBuilder.buildPlaceholder(
ctx,
count,
size: Size.square(tileHeight),
spacing: spacing,
);
}
// Bucket is already loaded, show the assets
if (timelineService.hasRange(assetIndex, count)) {
final assets = timelineService.getAssets(assetIndex, count);
return _buildAssetRow(ctx, assets);
}
// Bucket is not loaded, show placeholders and load the bucket
return FutureBuilder(
future: timelineService.loadAssets(assetIndex, count),
builder: (ctxx, snap) {
if (snap.connectionState != ConnectionState.done) {
return SegmentBuilder.buildPlaceholder(
ctx,
count,
size: Size.square(tileHeight),
spacing: spacing,
);
}
return _buildAssetRow(ctxx, snap.requireData);
},
);
},
);
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets) =>
FixedTimelineRow(
dimension: tileHeight,
spacing: spacing,
textDirection: Directionality.of(context),
children: List.generate(
assets.length,
(i) => RepaintBoundary(child: ThumbnailTile(assets[i])),
),
);
}

View File

@ -0,0 +1,75 @@
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
class FixedSegmentBuilder extends SegmentBuilder {
final double tileHeight;
final int columnCount;
const FixedSegmentBuilder({
required super.buckets,
required this.tileHeight,
required this.columnCount,
super.spacing,
super.groupBy,
});
List<Segment> generate() {
final segments = <Segment>[];
int firstIndex = 0;
double startOffset = 0;
int assetIndex = 0;
DateTime? previousDate;
for (int i = 0; i < buckets.length; i++) {
final bucket = buckets[i];
final assetCount = bucket.assetCount;
final numberOfRows = (assetCount / columnCount).ceil();
final segmentCount = numberOfRows + 1;
final segmentFirstIndex = firstIndex;
firstIndex += segmentCount;
final segmentLastIndex = firstIndex - 1;
final timelineHeader = switch (groupBy) {
GroupAssetsBy.month => HeaderType.month,
GroupAssetsBy.day =>
bucket is TimeBucket && bucket.date.month != previousDate?.month
? HeaderType.monthAndDay
: HeaderType.day,
GroupAssetsBy.none => HeaderType.none,
};
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
final segmentStartOffset = startOffset;
startOffset += headerExtent +
(tileHeight * numberOfRows) +
spacing * (numberOfRows - 1);
final segmentEndOffset = startOffset;
segments.add(
FixedSegment(
firstIndex: segmentFirstIndex,
lastIndex: segmentLastIndex,
startOffset: segmentStartOffset,
endOffset: segmentEndOffset,
firstAssetIndex: assetIndex,
bucket: bucket,
tileHeight: tileHeight,
columnCount: columnCount,
headerExtent: headerExtent,
spacing: spacing,
header: timelineHeader,
),
);
assetIndex += assetCount;
if (bucket is TimeBucket) {
previousDate = bucket.date;
}
}
return segments;
}
}

View File

@ -0,0 +1,60 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class TimelineHeader extends StatelessWidget {
final Bucket bucket;
final HeaderType header;
final double height;
const TimelineHeader({
super.key,
required this.bucket,
required this.header,
required this.height,
});
String _formatMonth(BuildContext context, DateTime date) {
final formatter = date.year == DateTime.now().year
? DateFormat.MMMM(context.locale.toLanguageTag())
: DateFormat.yMMMM(context.locale.toLanguageTag());
return formatter.format(date);
}
String _formatDay(BuildContext context, DateTime date) {
final formatter = DateFormat.yMMMEd(context.locale.toLanguageTag());
return formatter.format(date);
}
@override
Widget build(BuildContext context) {
if (bucket is! TimeBucket || header == HeaderType.none) {
return const SizedBox.shrink();
}
final date = (bucket as TimeBucket).date;
return Container(
padding: const EdgeInsets.only(left: 10, top: 30, bottom: 10),
height: height,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
if (header == HeaderType.month || header == HeaderType.monthAndDay)
Text(
_formatMonth(context, date),
style: context.textTheme.labelLarge
?.copyWith(fontSize: 24, fontWeight: FontWeight.w500),
),
if (header == HeaderType.day || header == HeaderType.monthAndDay)
Text(
_formatDay(context, date),
style: context.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.w500),
),
],
),
);
}
}

View File

@ -0,0 +1,455 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:intl/intl.dart' hide TextDirection;
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
class Scrubber extends StatefulWidget {
/// The view that will be scrolled with the scroll thumb
final CustomScrollView child;
/// The segments of the timeline
final List<Segment> layoutSegments;
final double timelineHeight;
final double topPadding;
final double bottomPadding;
Scrubber({
super.key,
Key? scrollThumbKey,
required this.layoutSegments,
required this.timelineHeight,
this.topPadding = 0,
this.bottomPadding = 0,
required this.child,
}) : assert(child.scrollDirection == Axis.vertical);
@override
State createState() => ScrubberState();
}
List<_Segment> _buildSegments({
required List<Segment> layoutSegments,
required double timelineHeight,
}) {
final segments = <_Segment>[];
if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) {
return [];
}
final formatter = DateFormat.yMMM();
for (final layoutSegment in layoutSegments) {
final scrollPercentage =
layoutSegment.startOffset / layoutSegments.last.endOffset;
final startOffset = scrollPercentage * timelineHeight;
final date = (layoutSegment.bucket as TimeBucket).date;
final label = formatter.format(date);
segments.add(
_Segment(
date: date,
startOffset: startOffset,
scrollLabel: label,
),
);
}
return segments;
}
class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
double _thumbTopOffset = 0.0;
bool _isDragging = false;
List<_Segment> _segments = [];
late AnimationController _thumbAnimationController;
Timer? _fadeOutTimer;
late Animation<double> _thumbAnimation;
late AnimationController _labelAnimationController;
late Animation<double> _labelAnimation;
double get _scrubberHeight =>
widget.timelineHeight - widget.topPadding - widget.bottomPadding;
late final ScrollController _scrollController;
double get _currentOffset =>
_scrollController.offset *
_scrubberHeight /
_scrollController.position.maxScrollExtent;
@override
void initState() {
super.initState();
_isDragging = false;
_segments = _buildSegments(
layoutSegments: widget.layoutSegments,
timelineHeight: _scrubberHeight,
);
_thumbAnimationController = AnimationController(
vsync: this,
duration: kTimelineScrubberFadeInDuration,
);
_thumbAnimation = CurvedAnimation(
parent: _thumbAnimationController,
curve: Curves.fastEaseInToSlowEaseOut,
);
_labelAnimationController = AnimationController(
vsync: this,
duration: kTimelineScrubberFadeInDuration,
);
_labelAnimation = CurvedAnimation(
parent: _labelAnimationController,
curve: Curves.fastOutSlowIn,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scrollController = PrimaryScrollController.of(context);
}
@override
void didUpdateWidget(covariant Scrubber oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.layoutSegments.lastOrNull?.endOffset !=
widget.layoutSegments.lastOrNull?.endOffset) {
_segments = _buildSegments(
layoutSegments: widget.layoutSegments,
timelineHeight: _scrubberHeight,
);
}
}
@override
void dispose() {
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeOutTimer?.cancel();
super.dispose();
}
void _resetThumbTimer() {
_fadeOutTimer?.cancel();
_fadeOutTimer = Timer(kTimelineScrubberFadeOutDuration, () {
_thumbAnimationController.reverse();
_fadeOutTimer = null;
});
}
bool _onScrollNotification(ScrollNotification notification) {
if (_isDragging) {
// If the user is dragging the thumb, we don't want to update the position
return false;
}
setState(() {
if (notification is ScrollUpdateNotification) {
_thumbTopOffset = _currentOffset;
if (_labelAnimation.status != AnimationStatus.reverse) {
_labelAnimationController.reverse();
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
}
_resetThumbTimer();
});
return false;
}
void _onDragStart(WidgetRef ref) {
ref.read(timelineStateProvider.notifier).setScrubbing(true);
setState(() {
_isDragging = true;
_labelAnimationController.forward();
_fadeOutTimer?.cancel();
});
}
void _onDragUpdate(DragUpdateDetails details) {
if (!_isDragging) {
return;
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
final newOffset =
details.globalPosition.dy - widget.topPadding - widget.bottomPadding;
setState(() {
_thumbTopOffset = newOffset.clamp(0, _scrubberHeight);
final scrollPercentage = _thumbTopOffset / _scrubberHeight;
final maxScrollExtent = _scrollController.position.maxScrollExtent;
_scrollController.jumpTo(maxScrollExtent * scrollPercentage);
});
}
void _onDragEnd(WidgetRef ref) {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
_labelAnimationController.reverse();
_isDragging = false;
_resetThumbTimer();
}
@override
Widget build(BuildContext ctx) {
Text? label;
if (_scrollController.hasClients) {
// Cache to avoid multiple calls to [_currentOffset]
final scrollOffset = _currentOffset;
final labelText = _segments
.lastWhereOrNull(
(segment) => segment.startOffset <= scrollOffset,
)
?.scrollLabel ??
_segments.firstOrNull?.scrollLabel;
label = labelText != null
? Text(
labelText,
style: ctx.textTheme.bodyLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
)
: null;
}
return NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: Stack(
children: [
RepaintBoundary(child: widget.child),
PositionedDirectional(
top: _thumbTopOffset + widget.topPadding,
end: 0,
child: Consumer(
builder: (_, ref, child) => GestureDetector(
onVerticalDragStart: (_) => _onDragStart(ref),
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: (_) => _onDragEnd(ref),
child: child,
),
child: _Scrubber(
thumbAnimation: _thumbAnimation,
labelAnimation: _labelAnimation,
label: label,
),
),
),
],
),
);
}
}
class _ScrollLabel extends StatelessWidget {
final Text label;
final Color backgroundColor;
final Animation<double> animation;
const _ScrollLabel({
required this.label,
required this.backgroundColor,
required this.animation,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: FadeTransition(
opacity: animation,
child: Container(
margin: const EdgeInsets.only(right: 12.0),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container(
constraints: const BoxConstraints(maxHeight: 28),
padding: const EdgeInsets.symmetric(horizontal: 10.0),
alignment: Alignment.center,
child: label,
),
),
),
),
);
}
}
class _Scrubber extends StatelessWidget {
final Text? label;
final Animation<double> thumbAnimation;
final Animation<double> labelAnimation;
const _Scrubber({
this.label,
required this.thumbAnimation,
required this.labelAnimation,
});
@override
Widget build(BuildContext context) {
final backgroundColor = context.isDarkTheme
? context.colorScheme.primary.darken(amount: .5)
: context.colorScheme.primary;
return _SlideFadeTransition(
animation: thumbAnimation,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (label != null)
_ScrollLabel(
label: label!,
backgroundColor: backgroundColor,
animation: labelAnimation,
),
_CircularThumb(backgroundColor),
],
),
);
}
}
class _CircularThumb extends StatelessWidget {
final Color backgroundColor;
const _CircularThumb(this.backgroundColor);
@override
Widget build(BuildContext context) {
return CustomPaint(
foregroundPainter: const _ArrowPainter(Colors.white),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(48.0),
bottomLeft: Radius.circular(48.0),
topRight: Radius.circular(4.0),
bottomRight: Radius.circular(4.0),
),
child: Container(
constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0)),
),
),
);
}
}
class _ArrowPainter extends CustomPainter {
final Color color;
const _ArrowPainter(this.color);
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
const width = 12.0;
const height = 8.0;
final baseX = size.width / 2;
final baseY = size.height / 2;
canvas.drawPath(
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
paint,
);
canvas.drawPath(
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
paint,
);
}
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
return Path()
..moveTo(o.dx, o.dy)
..lineTo(o.dx + width, o.dy)
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
..close();
}
}
class _SlideFadeTransition extends StatelessWidget {
final Animation<double> _animation;
final Widget _child;
const _SlideFadeTransition({
required Animation<double> animation,
required Widget child,
}) : _animation = animation,
_child = child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) =>
_animation.value == 0.0 ? const SizedBox() : child!,
child: SlideTransition(
position: Tween(
begin: const Offset(0.3, 0.0),
end: const Offset(0.0, 0.0),
).animate(_animation),
child: FadeTransition(
opacity: _animation,
child: _child,
),
),
);
}
}
class _Segment {
final DateTime date;
final double startOffset;
final String scrollLabel;
const _Segment({
required this.date,
required this.startOffset,
required this.scrollLabel,
});
_Segment copyWith({
DateTime? date,
double? startOffset,
String? scrollLabel,
}) {
return _Segment(
date: date ?? this.date,
startOffset: startOffset ?? this.startOffset,
scrollLabel: scrollLabel ?? this.scrollLabel,
);
}
@override
String toString() {
return 'Segment(scrollLabel: $scrollLabel, date: $date)';
}
}

View File

@ -0,0 +1,100 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
// Segments are the time groups buckets in the timeline view.
// Each segment contains a header and a list of asset rows.
abstract class Segment {
// The index of the first row of the segment, usually the header, but if not, it can be any asset.
final int firstIndex;
// The index of the last asset of the segment.
final int lastIndex;
// The offset of the first row from beginning of the timeline.
final double startOffset;
// The offset of the last row from beginning of the timeline.
final double endOffset;
// The spacing between the header and the first row of the segment.
final double spacing;
final double headerExtent;
// the start index of the asset of this segment from the beginning of the timeline.
final int firstAssetIndex;
final Bucket bucket;
// The index of the row after the header
final int gridIndex;
// The offset of the row after the header
final double gridOffset;
// The type of the header
final HeaderType header;
const Segment({
required this.firstIndex,
required this.lastIndex,
required this.startOffset,
required this.endOffset,
required this.firstAssetIndex,
required this.bucket,
required this.headerExtent,
required this.spacing,
required this.header,
}) : gridIndex = firstIndex + 1,
gridOffset = startOffset + headerExtent + spacing;
bool containsIndex(int index) => firstIndex <= index && index <= lastIndex;
bool isWithinOffset(double offset) =>
startOffset <= offset && offset <= endOffset;
int getMinChildIndexForScrollOffset(double scrollOffset);
int getMaxChildIndexForScrollOffset(double scrollOffset);
double indexToLayoutOffset(int index);
Widget builder(BuildContext context, int index);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Segment &&
other.firstIndex == firstIndex &&
other.lastIndex == lastIndex &&
other.startOffset == startOffset &&
other.endOffset == endOffset &&
other.spacing == spacing &&
other.firstAssetIndex == firstAssetIndex &&
other.headerExtent == headerExtent &&
other.gridIndex == gridIndex &&
other.gridOffset == gridOffset &&
other.header == header;
}
@override
int get hashCode =>
firstIndex.hashCode ^
lastIndex.hashCode ^
startOffset.hashCode ^
endOffset.hashCode ^
spacing.hashCode ^
headerExtent.hashCode ^
firstAssetIndex.hashCode ^
gridIndex.hashCode ^
gridOffset.hashCode ^
header.hashCode;
@override
String toString() {
return 'Segment(firstIndex: $firstIndex, lastIndex: $lastIndex)';
}
}
extension SegmentListExtension on List<Segment> {
bool equals(List<Segment> other) =>
length == other.length &&
lastOrNull?.endOffset == other.lastOrNull?.endOffset;
Segment? findByIndex(int index) =>
firstWhereOrNull((s) => s.containsIndex(index));
Segment? findByOffset(double offset) =>
firstWhereOrNull((s) => s.isWithinOffset(offset)) ?? lastOrNull;
}

View File

@ -0,0 +1,48 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
abstract class SegmentBuilder {
final List<Bucket> buckets;
final double spacing;
final GroupAssetsBy groupBy;
const SegmentBuilder({
required this.buckets,
this.spacing = kTimelineSpacing,
this.groupBy = GroupAssetsBy.day,
});
static double headerExtent(HeaderType header) {
switch (header) {
case HeaderType.month:
return kTimelineHeaderExtent;
case HeaderType.day:
return kTimelineHeaderExtent * 0.90;
case HeaderType.monthAndDay:
return kTimelineHeaderExtent * 1.5;
case HeaderType.none:
return 0.0;
}
}
static Widget buildPlaceholder(
BuildContext context,
int count, {
Size size = const Size.square(kTimelineFixedTileExtent),
double spacing = kTimelineSpacing,
}) =>
RepaintBoundary(
child: FixedTimelineRow(
dimension: size.height,
spacing: spacing,
textDirection: Directionality.of(context),
children: List.generate(
count,
(_) => ThumbnailPlaceholder(width: size.width, height: size.height),
),
),
);
}

View File

@ -0,0 +1,100 @@
import 'dart:math' as math;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class TimelineArgs {
final double maxWidth;
final double maxHeight;
final double spacing;
final int columnCount;
const TimelineArgs({
required this.maxWidth,
required this.maxHeight,
this.spacing = kTimelineSpacing,
this.columnCount = kTimelineColumnCount,
});
@override
bool operator ==(covariant TimelineArgs other) {
return spacing == other.spacing &&
maxWidth == other.maxWidth &&
maxHeight == other.maxHeight &&
columnCount == other.columnCount;
}
@override
int get hashCode =>
maxWidth.hashCode ^
maxHeight.hashCode ^
spacing.hashCode ^
columnCount.hashCode;
}
class TimelineState {
final bool isScrubbing;
const TimelineState({this.isScrubbing = false});
@override
bool operator ==(covariant TimelineState other) {
return isScrubbing == other.isScrubbing;
}
@override
int get hashCode => isScrubbing.hashCode;
TimelineState copyWith({bool? isScrubbing}) {
return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing);
}
}
class TimelineStateNotifier extends Notifier<TimelineState> {
TimelineStateNotifier();
void setScrubbing(bool isScrubbing) {
state = state.copyWith(isScrubbing: isScrubbing);
}
@override
TimelineState build() => const TimelineState(isScrubbing: false);
}
// This provider watches the buckets from the timeline service & args and serves the segments.
// It should be used only after the timeline service and timeline args provider is overridden
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>(
(ref) async* {
final args = ref.watch(timelineArgsProvider);
final columnCount = args.columnCount;
final spacing = args.spacing;
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
final tileExtent = math.max(0, availableTileWidth) / columnCount;
final groupBy = GroupAssetsBy
.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
final timelineService = ref.watch(timelineServiceProvider);
yield* timelineService.watchBuckets().map((buckets) {
return FixedSegmentBuilder(
buckets: buckets,
tileHeight: tileExtent,
columnCount: columnCount,
spacing: spacing,
groupBy: groupBy,
).generate();
});
},
dependencies: [timelineServiceProvider, timelineArgsProvider],
);
final timelineStateProvider =
NotifierProvider<TimelineStateNotifier, TimelineState>(
TimelineStateNotifier.new,
);

View File

@ -0,0 +1,365 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.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/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class Timeline extends StatelessWidget {
const Timeline({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: LayoutBuilder(
builder: (_, constraints) => ProviderScope(
overrides: [
timelineArgsProvider.overrideWith(
(ref) => TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: ref.watch(
settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
),
),
),
],
child: const _SliverTimeline(),
),
),
);
}
}
class _SliverTimeline extends StatefulWidget {
const _SliverTimeline();
@override
State createState() => _SliverTimelineState();
}
class _SliverTimelineState extends State<_SliverTimeline> {
final _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext _) {
return Consumer(
builder: (context, ref, child) {
final asyncSegments = ref.watch(timelineSegmentProvider);
final maxHeight =
ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
return asyncSegments.widgetWhen(
onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
return PrimaryScrollController(
controller: _scrollController,
child: Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: context.padding.top + 10,
bottomPadding: context.padding.bottom + 10,
child: CustomScrollView(
primary: true,
cacheExtent: maxHeight * 2,
slivers: [
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ??
const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
],
),
),
);
},
);
},
);
}
}
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
final List<Segment> _segments;
const _SliverSegmentedList({
required List<Segment> segments,
required super.delegate,
}) : _segments = segments;
@override
_RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) =>
_RenderSliverTimelineBoxAdaptor(
childManager: context as SliverMultiBoxAdaptorElement,
segments: _segments,
);
@override
void updateRenderObject(
BuildContext context,
_RenderSliverTimelineBoxAdaptor renderObject,
) {
renderObject.segments = _segments;
}
}
/// Modified version of [RenderSliverFixedExtentBoxAdaptor] to use precomputed offsets
class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
List<Segment> _segments;
set segments(List<Segment> updatedSegments) {
if (_segments.equals(updatedSegments)) {
return;
}
_segments = updatedSegments;
markNeedsLayout();
}
_RenderSliverTimelineBoxAdaptor({
required super.childManager,
required List<Segment> segments,
}) : _segments = segments;
int getMinChildIndexForScrollOffset(double offset) =>
_segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ??
0;
int getMaxChildIndexForScrollOffset(double offset) =>
_segments.findByOffset(offset)?.getMaxChildIndexForScrollOffset(offset) ??
0;
double indexToLayoutOffset(int index) =>
(_segments.findByIndex(index) ?? _segments.lastOrNull)
?.indexToLayoutOffset(index) ??
0;
double estimateMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
double computeMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
@override
void performLayout() {
childManager.didStartLayout();
// Assume initially that we have enough children to fill the viewport/cache area.
childManager.setDidUnderflow(false);
final double scrollOffset =
constraints.scrollOffset + constraints.cacheOrigin;
assert(scrollOffset >= 0.0);
final double remainingExtent = constraints.remainingCacheExtent;
assert(remainingExtent >= 0.0);
final double targetScrollOffset = scrollOffset + remainingExtent;
// Find the index of the first child that should be visible or in the leading cache area.
final int firstRequiredChildIndex =
getMinChildIndexForScrollOffset(scrollOffset);
// Find the index of the last child that should be visible or in the trailing cache area.
final int? lastRequiredChildIndex = targetScrollOffset.isFinite
? getMaxChildIndexForScrollOffset(targetScrollOffset)
: null;
// Remove children that are no longer visible or within the cache area.
if (firstChild == null) {
collectGarbage(0, 0);
} else {
final int leadingChildrenToRemove =
calculateLeadingGarbage(firstIndex: firstRequiredChildIndex);
final int trailingChildrenToRemove = lastRequiredChildIndex == null
? 0
: calculateTrailingGarbage(lastIndex: lastRequiredChildIndex);
collectGarbage(leadingChildrenToRemove, trailingChildrenToRemove);
}
// If there are currently no children laid out (e.g., initial load),
// try to add the first child needed for the current scroll offset.
if (firstChild == null) {
final double firstChildLayoutOffset =
indexToLayoutOffset(firstRequiredChildIndex);
final bool childAdded = addInitialChild(
index: firstRequiredChildIndex,
layoutOffset: firstChildLayoutOffset,
);
if (!childAdded) {
// There are either no children, or we are past the end of all our children.
final double max =
firstRequiredChildIndex <= 0 ? 0.0 : computeMaxScrollOffset();
geometry = SliverGeometry(scrollExtent: max, maxPaintExtent: max);
childManager.didFinishLayout();
return;
}
}
// Layout children that might have scrolled into view from the top (before the current firstChild).
RenderBox? highestLaidOutChild;
final childConstraints = constraints.asBoxConstraints();
for (int currentIndex = indexOf(firstChild!) - 1;
currentIndex >= firstRequiredChildIndex;
--currentIndex) {
final RenderBox? newLeadingChild =
insertAndLayoutLeadingChild(childConstraints);
if (newLeadingChild == null) {
// If a child is missing where we expect one, it indicates
// an inconsistency in offset that needs correction.
final Segment? segment =
_segments.findByIndex(currentIndex) ?? _segments.firstOrNull;
geometry = SliverGeometry(
// Request a scroll correction based on where the missing child should have been.
scrollOffsetCorrection:
segment?.indexToLayoutOffset(currentIndex) ?? 0.0,
);
// Parent will re-layout everything.
return;
}
final childParentData =
newLeadingChild.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
assert(childParentData.index == currentIndex);
highestLaidOutChild ??= newLeadingChild;
}
// If the loop above didn't run (meaning the firstChild was already the correct [firstRequiredChildIndex]),
// or even if it did, we need to ensure the first visible child is correctly laid out
// and establish our starting point for laying out trailing children.
// If [highestLaidOutChild] is still null, it means the loop above didn't add any new leading children.
// The [firstChild] that existed at the start of performLayout is still the first one we need.
if (highestLaidOutChild == null) {
firstChild!.layout(childConstraints);
final childParentData =
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset =
indexToLayoutOffset(firstRequiredChildIndex);
highestLaidOutChild = firstChild;
}
RenderBox? mostRecentlyLaidOutChild = highestLaidOutChild;
// Starting from the child after [mostRecentlyLaidOutChild], layout subsequent children
// until we reach the [lastRequiredChildIndex] or run out of children.
double calculatedMaxScrollOffset = double.infinity;
for (int currentIndex = indexOf(mostRecentlyLaidOutChild!) + 1;
lastRequiredChildIndex == null ||
currentIndex <= lastRequiredChildIndex;
++currentIndex) {
RenderBox? child = childAfter(mostRecentlyLaidOutChild!);
if (child == null || indexOf(child) != currentIndex) {
child = insertAndLayoutChild(
childConstraints,
after: mostRecentlyLaidOutChild,
);
if (child == null) {
final Segment? segment =
_segments.findByIndex(currentIndex) ?? _segments.lastOrNull;
calculatedMaxScrollOffset =
segment?.indexToLayoutOffset(currentIndex) ??
computeMaxScrollOffset();
break;
}
} else {
child.layout(childConstraints);
}
mostRecentlyLaidOutChild = child;
final childParentData = mostRecentlyLaidOutChild.parentData!
as SliverMultiBoxAdaptorParentData;
assert(childParentData.index == currentIndex);
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
}
final int lastLaidOutChildIndex = indexOf(lastChild!);
final double leadingScrollOffset =
indexToLayoutOffset(firstRequiredChildIndex);
final double trailingScrollOffset =
indexToLayoutOffset(lastLaidOutChildIndex + 1);
assert(
firstRequiredChildIndex == 0 ||
(childScrollOffset(firstChild!) ?? -1.0) - scrollOffset <=
precisionErrorTolerance,
);
assert(debugAssertChildListIsNonEmptyAndContiguous());
assert(indexOf(firstChild!) == firstRequiredChildIndex);
assert(
lastRequiredChildIndex == null ||
lastLaidOutChildIndex <= lastRequiredChildIndex,
);
calculatedMaxScrollOffset = math.min(
calculatedMaxScrollOffset,
estimateMaxScrollOffset(),
);
final double paintExtent = calculatePaintOffset(
constraints,
from: leadingScrollOffset,
to: trailingScrollOffset,
);
final double cacheExtent = calculateCacheOffset(
constraints,
from: leadingScrollOffset,
to: trailingScrollOffset,
);
final double targetEndScrollOffsetForPaint =
constraints.scrollOffset + constraints.remainingPaintExtent;
final int? targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite
? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint)
: null;
final maxPaintExtent = math.max(paintExtent, calculatedMaxScrollOffset);
geometry = SliverGeometry(
scrollExtent: calculatedMaxScrollOffset,
paintExtent: paintExtent,
maxPaintExtent: maxPaintExtent,
// Indicates if there's content scrolled off-screen.
// This is true if the last child needed for painting is actually laid out,
// or if the first child is partially visible.
hasVisualOverflow: (targetLastIndexForPaint != null &&
lastLaidOutChildIndex >= targetLastIndexForPaint) ||
constraints.scrollOffset > 0.0,
cacheExtent: cacheExtent,
);
// We may have started the layout while scrolled to the end, which would not
// expose a new child.
if (calculatedMaxScrollOffset == trailingScrollOffset) {
childManager.setDidUnderflow(true);
}
childManager.didFinishLayout();
}
}

View File

@ -37,8 +37,7 @@ class ImageLoader {
} else if (result is FileInfo) { } else if (result is FileInfo) {
// We have the file // We have the file
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path); final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
final decoded = await decode(buffer); return decode(buffer);
return decoded;
} }
} }

View File

@ -0,0 +1,22 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
class SettingsNotifier extends Notifier<SettingsService> {
@override
SettingsService build() =>
SettingsService(storeService: ref.read(storeServiceProvider));
T get<T>(Setting<T> setting) => state.get(setting);
Future<void> set<T>(Setting<T> setting, T value) async {
await state.set(setting, value);
ref.invalidateSelf();
}
Stream<T> watch<T>(Setting<T> setting) => state.watch(setting);
}
final settingsProvider =
NotifierProvider<SettingsNotifier, SettingsService>(SettingsNotifier.new);

View File

@ -0,0 +1,28 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/interfaces/timeline.interface.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
final timelineRepositoryProvider = Provider<ITimelineRepository>(
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
);
final timelineArgsProvider = Provider.autoDispose<TimelineArgs>(
(ref) =>
throw UnimplementedError('Will be overridden through a ProviderScope.'),
);
final timelineServiceProvider = Provider.autoDispose<TimelineService>(
(ref) =>
throw UnimplementedError('Will be overridden through a ProviderScope.'),
);
final timelineFactoryProvider = Provider<TimelineFactory>(
(ref) => TimelineFactory(
timelineRepository: ref.watch(timelineRepositoryProvider),
settingsService: ref.watch(settingsProvider),
),
);

View File

@ -66,6 +66,8 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
@ -340,5 +342,13 @@ class AppRouter extends RootStackRouter {
page: ExpBackupAlbumSelectionRoute.page, page: ExpBackupAlbumSelectionRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
), ),
AutoRoute(
page: LocalTimelineRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: MainTimelineRoute.page,
guards: [_authGuard, _duplicateGuard],
),
]; ];
} }

View File

@ -903,6 +903,43 @@ class LocalMediaSummaryRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [LocalTimelinePage]
class LocalTimelineRoute extends PageRouteInfo<LocalTimelineRouteArgs> {
LocalTimelineRoute({
Key? key,
required String albumId,
List<PageRouteInfo>? children,
}) : super(
LocalTimelineRoute.name,
args: LocalTimelineRouteArgs(key: key, albumId: albumId),
initialChildren: children,
);
static const String name = 'LocalTimelineRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<LocalTimelineRouteArgs>();
return LocalTimelinePage(key: args.key, albumId: args.albumId);
},
);
}
class LocalTimelineRouteArgs {
const LocalTimelineRouteArgs({this.key, required this.albumId});
final Key? key;
final String albumId;
@override
String toString() {
return 'LocalTimelineRouteArgs{key: $key, albumId: $albumId}';
}
}
/// generated route for /// generated route for
/// [LockedPage] /// [LockedPage]
class LockedRoute extends PageRouteInfo<void> { class LockedRoute extends PageRouteInfo<void> {
@ -935,6 +972,22 @@ class LoginRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [MainTimelinePage]
class MainTimelineRoute extends PageRouteInfo<void> {
const MainTimelineRoute({List<PageRouteInfo>? children})
: super(MainTimelineRoute.name, initialChildren: children);
static const String name = 'MainTimelineRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const MainTimelinePage();
},
);
}
/// generated route for /// generated route for
/// [MapLocationPickerPage] /// [MapLocationPickerPage]
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> { class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {

View File

@ -1,10 +1,10 @@
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/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/memories/memory.model.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:immich_mobile/utils/translation.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
final memoryServiceProvider = StateProvider<MemoryService>((ref) { final memoryServiceProvider = StateProvider<MemoryService>((ref) {
@ -40,7 +40,11 @@ class MemoryService {
.getAllByRemoteId(memory.assets.map((e) => e.id)); .getAllByRemoteId(memory.assets.map((e) => e.id));
final yearsAgo = now.year - memory.data.year; final yearsAgo = now.year - memory.data.year;
if (dbAssets.isNotEmpty) { if (dbAssets.isNotEmpty) {
final String title = t('years_ago', {'years': yearsAgo.toString()}); final String title = 'years_ago'.t(
args: {
'years': yearsAgo.toString(),
},
);
memories.add( memories.add(
Memory( Memory(
title: title, title: title,

View File

@ -6,10 +6,10 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:immich_mobile/extensions/asset_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/services/share.service.dart'; import 'package:immich_mobile/services/share.service.dart';
import 'package:immich_mobile/utils/translation.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.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/location_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart';
@ -59,10 +59,11 @@ Future<void> handleArchiveAssets(
await ref await ref
.read(assetProvider.notifier) .read(assetProvider.notifier)
.toggleArchive(selection, shouldArchive); .toggleArchive(selection, shouldArchive);
final message = shouldArchive final message = shouldArchive
? t('moved_to_archive', {'count': selection.length}) ? 'moved_to_archive'
: t('moved_to_library', {'count': selection.length}); .t(context: context, args: {'count': selection.length})
: 'moved_to_library'
.t(context: context, args: {'count': selection.length});
if (context.mounted) { if (context.mounted) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,

View File

@ -1,7 +1,7 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/translation.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
String getAltText( String getAltText(
ExifInfo? exifInfo, ExifInfo? exifInfo,
@ -14,7 +14,7 @@ String getAltText(
} }
final (template, args) = final (template, args) =
getAltTextTemplate(exifInfo, fileCreatedAt, type, peopleNames); getAltTextTemplate(exifInfo, fileCreatedAt, type, peopleNames);
return t(template, args); return template.t(args: args);
} }
(String, Map<String, String>) getAltTextTemplate( (String, Map<String, String>) getAltTextTemplate(

View File

@ -1,15 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:intl/message_format.dart';
String t(String key, [Map<String, Object>? args]) {
try {
String message = key.tr();
if (args != null) {
return MessageFormat(message, locale: Intl.defaultLocale ?? 'en')
.format(args);
}
return message;
} catch (e) {
return key;
}
}

View File

@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/translation.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
class AlbumThumbnailCard extends ConsumerWidget { class AlbumThumbnailCard extends ConsumerWidget {
@ -62,7 +62,12 @@ class AlbumThumbnailCard extends ConsumerWidget {
if (album.ownerId == ref.read(currentUserProvider)?.id) { if (album.ownerId == ref.read(currentUserProvider)?.id) {
owner = 'owned'.tr(); owner = 'owned'.tr();
} else if (album.ownerName != null) { } else if (album.ownerName != null) {
owner = t('shared_by_user', {'user': album.ownerName!}); owner = 'shared_by_user'.t(
context: context,
args: {
'user': album.ownerName!,
},
);
} }
} }
@ -70,7 +75,12 @@ class AlbumThumbnailCard extends ConsumerWidget {
TextSpan( TextSpan(
children: [ children: [
TextSpan( TextSpan(
text: t('items_count', {'count': album.assetCount}), text: 'items_count'.t(
context: context,
args: {
'count': album.assetCount,
},
),
), ),
if (owner != null) const TextSpan(text: ''), if (owner != null) const TextSpan(text: ''),
if (owner != null) TextSpan(text: owner), if (owner != null) TextSpan(text: owner),

View File

@ -4,10 +4,10 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.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/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/translation.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class AlbumThumbnailListTile extends StatelessWidget { class AlbumThumbnailListTile extends StatelessWidget {
@ -91,7 +91,12 @@ class AlbumThumbnailListTile extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
t('items_count', {'count': album.assetCount}), 'items_count'.t(
context: context,
args: {
'count': album.assetCount,
},
),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
), ),

View File

@ -11,6 +11,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/models/asset_selection_state.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
@ -24,7 +25,6 @@ import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/services/stack.service.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.dart'; import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:immich_mobile/utils/translation.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
@ -257,13 +257,19 @@ class MultiselectGrid extends HookConsumerWidget {
final failedCount = totalCount - successCount; final failedCount = totalCount - successCount;
final msg = failedCount > 0 final msg = failedCount > 0
? t('assets_downloaded_failed', { ? 'assets_downloaded_failed'.t(
context: context,
args: {
'count': successCount, 'count': successCount,
'error': failedCount, 'error': failedCount,
}) },
: t('assets_downloaded_successfully', { )
: 'assets_downloaded_successfully'.t(
context: context,
args: {
'count': successCount, 'count': successCount,
}); },
);
ImmichToast.show( ImmichToast.show(
context: context, context: context,

View File

@ -1,9 +1,9 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:intl/intl.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/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/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
import 'package:immich_mobile/utils/translation.dart';
/// This is a simple debug widget which should be removed later on when we are /// This is a simple debug widget which should be removed later on when we are
/// more confident about background sync /// more confident about background sync
@ -22,29 +22,28 @@ class IosDebugInfoTile extends HookConsumerWidget {
final String title; final String title;
if (processes == 0) { if (processes == 0) {
title = 'ios_debug_info_no_processes_queued'.tr(); title = 'ios_debug_info_no_processes_queued'.t(context: context);
} else { } else {
title = t('ios_debug_info_processes_queued', {'count': processes}); title = 'ios_debug_info_processes_queued'
.t(context: context, args: {'count': processes});
} }
final df = DateFormat.yMd().add_jm(); final df = DateFormat.yMd().add_jm();
final String subtitle; final String subtitle;
if (fetch == null && processing == null) { if (fetch == null && processing == null) {
subtitle = 'ios_debug_info_no_sync_yet'.tr(); subtitle = 'ios_debug_info_no_sync_yet'.t(context: context);
} else if (fetch != null && processing == null) { } else if (fetch != null && processing == null) {
subtitle = subtitle = 'ios_debug_info_fetch_ran_at'
t('ios_debug_info_fetch_ran_at', {'dateTime': df.format(fetch)}); .t(context: context, args: {'dateTime': df.format(fetch)});
} else if (processing != null && fetch == null) { } else if (processing != null && fetch == null) {
subtitle = t( subtitle = 'ios_debug_info_processing_ran_at'
'ios_debug_info_processing_ran_at', .t(context: context, args: {'dateTime': df.format(processing)});
{'dateTime': df.format(processing)},
);
} else { } else {
final fetchOrProcessing = final fetchOrProcessing =
fetch!.isAfter(processing!) ? fetch : processing; fetch!.isAfter(processing!) ? fetch : processing;
subtitle = t( subtitle = 'ios_debug_info_last_sync_at'.t(
'ios_debug_info_last_sync_at', context: context,
{'dateTime': df.format(fetchOrProcessing)}, args: {'dateTime': df.format(fetchOrProcessing)},
); );
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/common/search_field.dart';
@ -91,6 +92,7 @@ class LanguageSettings extends HookConsumerWidget {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemCount: filteredLocaleEntries.value.length, itemCount: filteredLocaleEntries.value.length,
itemExtent: 64.0, itemExtent: 64.0,
cacheExtent: 100,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final countryName = final countryName =
filteredLocaleEntries.value[index].key; filteredLocaleEntries.value[index].key;
@ -100,6 +102,7 @@ class LanguageSettings extends HookConsumerWidget {
selectedLocale.value == localeValue; selectedLocale.value == localeValue;
return _LanguageItem( return _LanguageItem(
key: ValueKey(localeValue.toString()),
countryName: countryName, countryName: countryName,
localeValue: localeValue, localeValue: localeValue,
isSelected: isSelected, isSelected: isSelected,
@ -162,7 +165,7 @@ class _LanguageSearchBar extends StatelessWidget {
child: SearchField( child: SearchField(
autofocus: false, autofocus: false,
contentPadding: const EdgeInsets.all(12), contentPadding: const EdgeInsets.all(12),
hintText: 'language_search_hint'.tr(), hintText: 'language_search_hint'.t(context: context),
prefixIcon: const Icon(Icons.search_rounded), prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: controller.text.isNotEmpty suffixIcon: controller.text.isNotEmpty
? IconButton( ? IconButton(
@ -196,14 +199,14 @@ class _LanguageNotFound extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'language_no_results_title'.tr(), 'language_no_results_title'.t(context: context),
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium?.copyWith(
color: context.colorScheme.onSurface, color: context.colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'language_no_results_subtitle'.tr(), 'language_no_results_subtitle'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith( style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.8), color: context.colorScheme.onSurface.withValues(alpha: 0.8),
), ),
@ -246,7 +249,7 @@ class _LanguageApplyButton extends StatelessWidget {
), ),
) )
: Text( : Text(
'setting_languages_apply'.tr(), 'setting_languages_apply'.t(context: context),
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 16.0, fontSize: 16.0,
@ -261,6 +264,7 @@ class _LanguageApplyButton extends StatelessWidget {
class _LanguageItem extends StatelessWidget { class _LanguageItem extends StatelessWidget {
const _LanguageItem({ const _LanguageItem({
super.key,
required this.countryName, required this.countryName,
required this.localeValue, required this.localeValue,
required this.isSelected, required this.isSelected,

View File

@ -1810,7 +1810,7 @@ packages:
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
stream_transform: stream_transform:
dependency: transitive dependency: "direct main"
description: description:
name: stream_transform name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871

View File

@ -63,6 +63,7 @@ dependencies:
share_handler: ^0.0.22 share_handler: ^0.0.22
share_plus: ^10.1.4 share_plus: ^10.1.4
socket_io_client: ^2.0.3+1 socket_io_client: ^2.0.3+1
stream_transform: ^2.1.1
thumbhash: 0.1.0+1 thumbhash: 0.1.0+1
timezone: ^0.9.4 timezone: ^0.9.4
url_launcher: ^6.3.1 url_launcher: ^6.3.1

View File

@ -48,6 +48,7 @@
"kysely-postgres-js": "^2.0.0", "kysely-postgres-js": "^2.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.4.2", "luxon": "^3.4.2",
"mnemonist": "^0.40.3",
"nest-commander": "^3.16.0", "nest-commander": "^3.16.0",
"nestjs-cls": "^5.0.0", "nestjs-cls": "^5.0.0",
"nestjs-kysely": "^1.1.0", "nestjs-kysely": "^1.1.0",
@ -473,6 +474,13 @@
"lru-cache": "^10.4.3" "lru-cache": "^10.4.3"
} }
}, },
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.26.2", "version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@ -3174,6 +3182,13 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/@npmcli/agent/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@npmcli/fs": { "node_modules/@npmcli/fs": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz",
@ -6859,6 +6874,12 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/archiver-utils/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/archiver-utils/node_modules/minimatch": { "node_modules/archiver-utils/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@ -7484,6 +7505,13 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/cacache/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/cacache/node_modules/minimatch": { "node_modules/cacache/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@ -11837,10 +11865,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "10.4.3", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"license": "ISC" "license": "ISC",
"engines": {
"node": "20 || >=22"
}
}, },
"node_modules/luxon": { "node_modules/luxon": {
"version": "3.6.1", "version": "3.6.1",
@ -12304,6 +12335,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/mock-fs": { "node_modules/mock-fs": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz",
@ -13061,6 +13101,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -13492,15 +13538,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/path-source": { "node_modules/path-source": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/path-source/-/path-source-0.1.3.tgz", "resolved": "https://registry.npmjs.org/path-source/-/path-source-0.1.3.tgz",
@ -16043,6 +16080,13 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/sucrase/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC",
"peer": true
},
"node_modules/sucrase/node_modules/minimatch": { "node_modules/sucrase/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@ -16596,6 +16640,13 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/test-exclude/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/test-exclude/node_modules/minimatch": { "node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@ -17196,6 +17247,12 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/typeorm/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/typeorm/node_modules/minimatch": { "node_modules/typeorm/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",

View File

@ -74,6 +74,7 @@
"kysely-postgres-js": "^2.0.0", "kysely-postgres-js": "^2.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.4.2", "luxon": "^3.4.2",
"mnemonist": "^0.40.3",
"nest-commander": "^3.16.0", "nest-commander": "^3.16.0",
"nestjs-cls": "^5.0.0", "nestjs-cls": "^5.0.0",
"nestjs-kysely": "^1.1.0", "nestjs-kysely": "^1.1.0",

View File

@ -179,9 +179,8 @@ export class PersonRepository {
) )
.$if(!options?.closestFaceAssetId, (qb) => .$if(!options?.closestFaceAssetId, (qb) =>
qb qb
.orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
.orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc') .orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc')
.orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`) .orderBy(sql`NULLIF(person.name, '') asc nulls last`)
.orderBy('person.createdAt'), .orderBy('person.createdAt'),
) )
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false)) .$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))

View File

@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { LRUMap } from 'mnemonist';
import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
@ -24,6 +25,8 @@ import { isSmartSearchEnabled } from 'src/utils/misc';
@Injectable() @Injectable()
export class SearchService extends BaseService { export class SearchService extends BaseService {
private embeddingCache = new LRUMap<string, string>(100);
async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> { async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
const people = await this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); const people = await this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
return people.map((person) => mapPerson(person)); return people.map((person) => mapPerson(person));
@ -98,16 +101,21 @@ export class SearchService extends BaseService {
throw new BadRequestException('Smart search is not enabled'); throw new BadRequestException('Smart search is not enabled');
} }
const userIds = await this.getUserIdsToSearch(auth); const userIds = this.getUserIdsToSearch(auth);
const embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, { const key = machineLearning.clip.modelName + dto.query + dto.language;
let embedding = this.embeddingCache.get(key);
if (!embedding) {
embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, {
modelName: machineLearning.clip.modelName, modelName: machineLearning.clip.modelName,
language: dto.language, language: dto.language,
}); });
this.embeddingCache.set(key, embedding);
}
const page = dto.page ?? 1; const page = dto.page ?? 1;
const size = dto.size || 100; const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart( const { hasNextPage, items } = await this.searchRepository.searchSmart(
{ page, size }, { page, size },
{ ...dto, userIds, embedding }, { ...dto, userIds: await userIds, embedding },
); );
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });

View File

@ -1,5 +1,3 @@
> 0.2% and last 4 major versions > 0.2% and last 4 major versions
> 0.5% > 0.5%
not dead not dead
edge >= 135
not edge < 135

View File

@ -4,6 +4,7 @@ import eslintPluginCompat from 'eslint-plugin-compat';
import eslintPluginSvelte from 'eslint-plugin-svelte'; import eslintPluginSvelte from 'eslint-plugin-svelte';
import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import globals from 'globals'; import globals from 'globals';
import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import parser from 'svelte-eslint-parser'; import parser from 'svelte-eslint-parser';
@ -23,7 +24,13 @@ export default typescriptEslint.config(
rules: { rules: {
'tscompat/tscompat': [ 'tscompat/tscompat': [
'error', 'error',
{ browserslist: ['> 0.2% and last 4 major versions', '> 0.5%', 'not dead', 'edge >= 135', 'not edge < 135'] }, {
browserslist: fs
.readFileSync(path.join(__dirname, '.browserslistrc'), 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#')),
},
], ],
}, },
languageOptions: { languageOptions: {

24
web/package-lock.json generated
View File

@ -3656,9 +3656,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.24.4", "version": "4.25.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
"integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -3676,10 +3676,10 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001688", "caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.73", "electron-to-chromium": "^1.5.160",
"node-releases": "^2.0.19", "node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.1" "update-browserslist-db": "^1.1.3"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@ -3752,9 +3752,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001713", "version": "1.0.30001723",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001723.tgz",
"integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==", "integrity": "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -4374,9 +4374,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.137", "version": "1.5.167",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.167.tgz",
"integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==", "integrity": "sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },

View File

@ -1,202 +0,0 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
AlbumUserRole,
AssetOrder,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Modal, ModalBody } from '@immich/ui';
import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js';
import { findKey } from 'lodash-es';
import { t } from 'svelte-i18n';
import type { RenderedOption } from '../elements/dropdown.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import SettingDropdown from '../shared-components/settings/setting-dropdown.svelte';
interface Props {
album: AlbumResponseDto;
order: AssetOrder | undefined;
user: UserResponseDto;
onChangeOrder: (order: AssetOrder) => void;
onClose: () => void;
onToggleEnabledActivity: () => void;
onShowSelectSharedUser: () => void;
onRemove: (userId: string) => void;
onRefreshAlbum: () => void;
}
let {
album,
order,
user,
onChangeOrder,
onClose,
onToggleEnabledActivity,
onShowSelectSharedUser,
onRemove,
onRefreshAlbum,
}: Props = $props();
let selectedRemoveUser: UserResponseDto | null = $state(null);
const options: Record<AssetOrder, RenderedOption> = {
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
};
let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
const handleToggle = async (returnedOption: RenderedOption): Promise<void> => {
if (selectedOption === returnedOption) {
return;
}
let order: AssetOrder = AssetOrder.Desc;
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
order,
},
});
onChangeOrder(order);
} catch (error) {
handleError(error, $t('errors.unable_to_save_album'));
}
};
const handleMenuRemove = (user: UserResponseDto): void => {
selectedRemoveUser = user;
};
const handleRemoveUser = async (): Promise<void> => {
if (!selectedRemoveUser) {
return;
}
try {
await removeUserFromAlbum({ id: album.id, userId: selectedRemoveUser.id });
onRemove(selectedRemoveUser.id);
notificationController.show({
type: NotificationType.Info,
message: $t('album_user_removed', { values: { user: selectedRemoveUser.name } }),
});
} catch (error) {
handleError(error, $t('errors.unable_to_remove_album_users'));
} finally {
selectedRemoveUser = null;
}
};
const handleUpdateSharedUserRole = async (user: UserResponseDto, role: AlbumUserRole) => {
try {
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
const message = $t('user_role_set', {
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
});
onRefreshAlbum();
notificationController.show({ type: NotificationType.Info, message });
} catch (error) {
handleError(error, $t('errors.unable_to_change_album_user_role'));
} finally {
selectedRemoveUser = null;
}
};
</script>
{#if !selectedRemoveUser}
<Modal title={$t('options')} {onClose} size="small">
<ModalBody>
<div class="items-center justify-center">
<div class="py-2">
<h2 class="text-gray text-sm mb-2">{$t('settings').toUpperCase()}</h2>
<div class="grid p-2 gap-y-2">
{#if order}
<SettingDropdown
title={$t('display_order')}
options={Object.values(options)}
selectedOption={options[order]}
onToggle={handleToggle}
/>
{/if}
<SettingSwitch
title={$t('comments_and_likes')}
subtitle={$t('let_others_respond')}
checked={album.isActivityEnabled}
onToggle={onToggleEnabledActivity}
/>
</div>
</div>
<div class="py-2">
<div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div>
<div class="p-2">
<button type="button" class="flex items-center gap-2" onclick={onShowSelectSharedUser}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div>
</div>
<div>{$t('invite_people')}</div>
</button>
<div class="flex items-center gap-2 py-2 mt-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
<div>{$t('owner')}</div>
</div>
{#each album.albumUsers as { user, role } (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
{#if role === AlbumUserRole.Viewer}
{$t('role_viewer')}
{:else}
{$t('role_editor')}
{/if}
{#if user.id !== album.ownerId}
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
{#if role === AlbumUserRole.Viewer}
<MenuOption
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Editor)}
text={$t('allow_edits')}
/>
{:else}
<MenuOption
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)}
text={$t('disallow_edits')}
/>
{/if}
<!-- Allow deletion for non-owners -->
<MenuOption onClick={() => handleMenuRemove(user)} text={$t('remove')} />
</ButtonContextMenu>
{/if}
</div>
{/each}
</div>
</div>
</div>
</ModalBody>
</Modal>
{/if}
{#if selectedRemoveUser}
<ConfirmModal
title={$t('album_remove_user')}
prompt={$t('album_remove_user_confirmation', { values: { user: selectedRemoveUser.name } })}
confirmText={$t('remove_user')}
onClose={(confirmed) => (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))}
/>
{/if}

View File

@ -53,12 +53,10 @@
img.src = getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash }); img.src = getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash });
img.addEventListener('load', () => onImageLoad(true)); img.addEventListener('load', () => onImageLoad(true), { passive: true });
img.addEventListener('error', (error) => { img.addEventListener('error', (error) => handleError(error, $t('error_loading_image')), { passive: true });
handleError(error, $t('error_loading_image'));
});
globalThis.addEventListener('mousemove', handleMouseMove); globalThis.addEventListener('mousemove', handleMouseMove, { passive: true });
}); });
onDestroy(() => { onDestroy(() => {

View File

@ -31,8 +31,8 @@ export function onImageLoad(resetSize: boolean = false) {
cropFrameEl?.classList.add('transition'); cropFrameEl?.classList.add('transition');
cropSettings.update((crop) => normalizeCropArea(crop, img, scale)); cropSettings.update((crop) => normalizeCropArea(crop, img, scale));
cropFrameEl?.classList.add('transition'); cropFrameEl?.classList.add('transition');
cropFrameEl?.addEventListener('transitionend', () => { cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), {
cropFrameEl?.classList.remove('transition'); passive: true,
}); });
} }
cropImageScale.set(scale); cropImageScale.set(scale);

View File

@ -58,7 +58,7 @@ export function handleMouseDown(e: MouseEvent) {
} }
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
globalThis.addEventListener('mouseup', handleMouseUp); globalThis.addEventListener('mouseup', handleMouseUp, { passive: true });
} }
export function handleMouseMove(e: MouseEvent) { export function handleMouseMove(e: MouseEvent) {

View File

@ -7,9 +7,9 @@
type AdapterConstructor, type AdapterConstructor,
type PluginConstructor, type PluginConstructor,
} from '@photo-sphere-viewer/core'; } from '@photo-sphere-viewer/core';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import '@photo-sphere-viewer/core/index.css'; import '@photo-sphere-viewer/core/index.css';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
import '@photo-sphere-viewer/settings-plugin/index.css'; import '@photo-sphere-viewer/settings-plugin/index.css';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
@ -68,8 +68,6 @@
fisheye: false, fisheye: false,
}); });
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin; const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
if (originalPanorama && !$alwaysLoadOriginalFile) {
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100] // zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) { if (Math.round(zoomLevel) >= 75) {
@ -78,8 +76,12 @@
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
} }
}; };
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
if (originalPanorama && !$alwaysLoadOriginalFile) {
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
} }
return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
}); });
onDestroy(() => { onDestroy(() => {

View File

@ -3,9 +3,10 @@
import { zoomImageAction } from '$lib/actions/zoom-image'; import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
@ -192,8 +193,8 @@
if (loader?.complete) { if (loader?.complete) {
onload(); onload();
} }
loader?.addEventListener('load', onload); loader?.addEventListener('load', onload, { passive: true });
loader?.addEventListener('error', onerror); loader?.addEventListener('error', onerror, { passive: true });
return () => { return () => {
loader?.removeEventListener('load', onload); loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror); loader?.removeEventListener('error', onerror);
@ -240,7 +241,7 @@
use:swipe={() => ({})} use:swipe={() => ({})}
onswipe={onSwipe} onswipe={onSwipe}
class="h-full w-full" class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
> >
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img <img

View File

@ -123,17 +123,21 @@
mouseOver = false; mouseOver = false;
}; };
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout> | null = null;
const preventContextMenu = (evt: Event) => evt.preventDefault(); const preventContextMenu = (evt: Event) => evt.preventDefault();
let disposeables: (() => void)[] = []; const disposeables: (() => void)[] = [];
const clearLongPressTimer = () => { const clearLongPressTimer = () => {
if (!timer) {
return;
}
clearTimeout(timer); clearTimeout(timer);
timer = null;
for (const dispose of disposeables) { for (const dispose of disposeables) {
dispose(); dispose();
} }
disposeables = []; disposeables.length = 0;
}; };
let startX: number = 0; let startX: number = 0;
@ -162,7 +166,7 @@
}; };
element.addEventListener('click', click); element.addEventListener('click', click);
element.addEventListener('pointerdown', start, true); element.addEventListener('pointerdown', start, true);
element.addEventListener('pointerup', clearLongPressTimer, true); element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
return { return {
destroy: () => { destroy: () => {
element.removeEventListener('click', click); element.removeEventListener('click', click);
@ -172,17 +176,15 @@
}; };
} }
function moveHandler(e: PointerEvent) { function moveHandler(e: PointerEvent) {
var diffX = Math.abs(startX - e.clientX); if (Math.abs(startX - e.clientX) >= 10 || Math.abs(startY - e.clientY) >= 10) {
var diffY = Math.abs(startY - e.clientY);
if (diffX >= 10 || diffY >= 10) {
clearLongPressTimer(); clearLongPressTimer();
} }
} }
onMount(() => { onMount(() => {
document.addEventListener('scroll', clearLongPressTimer, true); document.addEventListener('scroll', clearLongPressTimer, { capture: true, passive: true });
document.addEventListener('wheel', clearLongPressTimer, true); document.addEventListener('wheel', clearLongPressTimer, { capture: true, passive: true });
document.addEventListener('contextmenu', clearLongPressTimer, true); document.addEventListener('contextmenu', clearLongPressTimer, { capture: true, passive: true });
document.addEventListener('pointermove', moveHandler, true); document.addEventListener('pointermove', moveHandler, { capture: true, passive: true });
return () => { return () => {
document.removeEventListener('scroll', clearLongPressTimer, true); document.removeEventListener('scroll', clearLongPressTimer, true);
document.removeEventListener('wheel', clearLongPressTimer, true); document.removeEventListener('wheel', clearLongPressTimer, true);

View File

@ -23,12 +23,11 @@
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, assetViewerFadeDuration, QueryParameter } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
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';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte'; import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
@ -261,12 +260,7 @@
playerInitialized = true; playerInitialized = true;
}; };
afterNavigate(({ from, to, type }) => { afterNavigate(({ from, to }) => {
if (type === 'enter') {
// afterNavigate triggers twice on first page load (once when mounted with 'enter' and then a second time
// with the actual 'goto' to URL).
return;
}
memoryStore.initialize().then( memoryStore.initialize().then(
() => { () => {
let target = null; let target = null;
@ -469,7 +463,7 @@
> >
<div class="relative h-full w-full rounded-2xl bg-black"> <div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id} {#key current.asset.id}
<div transition:fade class="h-full w-full"> <div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
{#if current.asset.isVideo} {#if current.asset.isVideo}
<video <video
bind:this={videoPlayer} bind:this={videoPlayer}

View File

@ -21,7 +21,7 @@
<div class="flex flex-col"> <div class="flex flex-col">
<p> <p>
<FormatMessage key="admin.storage_template_onboarding_description"> <FormatMessage key="admin.storage_template_onboarding_description_v2">
{#snippet children({ message })} {#snippet children({ message })}
<a class="underline" href="https://immich.app/docs/administration/storage-template">{message}</a> <a class="underline" href="https://immich.app/docs/administration/storage-template">{message}</a>
{/snippet} {/snippet}

View File

@ -105,9 +105,9 @@
} }
observer.observe(input); observer.observe(input);
const scrollableAncestor = input?.closest('.overflow-y-auto, .overflow-y-scroll'); const scrollableAncestor = input?.closest('.overflow-y-auto, .overflow-y-scroll');
scrollableAncestor?.addEventListener('scroll', onPositionChange); scrollableAncestor?.addEventListener('scroll', onPositionChange, { passive: true });
window.visualViewport?.addEventListener('resize', onPositionChange); window.visualViewport?.addEventListener('resize', onPositionChange, { passive: true });
window.visualViewport?.addEventListener('scroll', onPositionChange); window.visualViewport?.addEventListener('scroll', onPositionChange, { passive: true });
return () => { return () => {
observer.disconnect(); observer.disconnect();

View File

@ -53,7 +53,7 @@
onMount(() => { onMount(() => {
if (browser) { if (browser) {
document.addEventListener('scroll', onScroll); document.addEventListener('scroll', onScroll, { passive: true });
} }
}); });

View File

@ -357,18 +357,18 @@
}; };
/* eslint-enable tscompat/tscompat */ /* eslint-enable tscompat/tscompat */
onMount(() => { onMount(() => {
document.addEventListener('touchmove', onTouchMove, true); document.addEventListener('touchmove', onTouchMove, { capture: true, passive: true });
return () => { return () => {
document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchmove', onTouchMove, true);
}; };
}); });
onMount(() => { onMount(() => {
document.addEventListener('touchstart', onTouchStart, true); document.addEventListener('touchstart', onTouchStart, { capture: true, passive: true });
document.addEventListener('touchend', onTouchEnd, true); document.addEventListener('touchend', onTouchEnd, { capture: true, passive: true });
return () => { return () => {
document.addEventListener('touchstart', onTouchStart, true); document.removeEventListener('touchstart', onTouchStart, true);
document.addEventListener('touchend', onTouchEnd, true); document.removeEventListener('touchend', onTouchEnd, true);
}; };
}); });

View File

@ -420,3 +420,5 @@ export enum ToggleVisibility {
HIDE_UNNANEMD = 'hide-unnamed', HIDE_UNNANEMD = 'hide-unnamed',
SHOW_ALL = 'show-all', SHOW_ALL = 'show-all',
} }
export const assetViewerFadeDuration: number = 150;

View File

@ -52,11 +52,15 @@ class ThemeManager {
} }
#onAppInit() { #onAppInit() {
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener(
'change',
() => {
if (this.theme.system) { if (this.theme.system) {
this.#update('system'); this.#update('system');
} }
}); },
{ passive: true },
);
} }
#update(value: Theme | 'system') { #update(value: Theme | 'system') {

View File

@ -0,0 +1,193 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
AlbumUserRole,
AssetOrder,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Modal, ModalBody } from '@immich/ui';
import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js';
import { findKey } from 'lodash-es';
import { t } from 'svelte-i18n';
import type { RenderedOption } from '../components/elements/dropdown.svelte';
import { notificationController, NotificationType } from '../components/shared-components/notification/notification';
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
interface Props {
album: AlbumResponseDto;
order: AssetOrder | undefined;
user: UserResponseDto;
onClose: (
result?: { action: 'changeOrder'; order: AssetOrder } | { action: 'shareUser' } | { action: 'refreshAlbum' },
) => void;
}
let { album, order, user, onClose }: Props = $props();
const options: Record<AssetOrder, RenderedOption> = {
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
};
let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
const handleToggleOrder = async (returnedOption: RenderedOption): Promise<void> => {
if (selectedOption === returnedOption) {
return;
}
let order: AssetOrder = AssetOrder.Desc;
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
order,
},
});
onClose({ action: 'changeOrder', order });
} catch (error) {
handleError(error, $t('errors.unable_to_save_album'));
}
};
const handleToggleActivity = async () => {
try {
album = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
isActivityEnabled: !album.isActivityEnabled,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('activity_changed', { values: { enabled: album.isActivityEnabled } }),
});
} catch (error) {
handleError(error, $t('errors.cant_change_activity', { values: { enabled: album.isActivityEnabled } }));
}
};
const handleRemoveUser = async (user: UserResponseDto): Promise<void> => {
const confirmed = await modalManager.showDialog({
title: $t('album_remove_user'),
prompt: $t('album_remove_user_confirmation', { values: { user: user.name } }),
confirmText: $t('remove_user'),
});
if (!confirmed) {
return;
}
try {
await removeUserFromAlbum({ id: album.id, userId: user.id });
onClose({ action: 'refreshAlbum' });
notificationController.show({
type: NotificationType.Info,
message: $t('album_user_removed', { values: { user: user.name } }),
});
} catch (error) {
handleError(error, $t('errors.unable_to_remove_album_users'));
}
};
const handleUpdateSharedUserRole = async (user: UserResponseDto, role: AlbumUserRole) => {
try {
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
const message = $t('user_role_set', {
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
});
onClose({ action: 'refreshAlbum' });
notificationController.show({ type: NotificationType.Info, message });
} catch (error) {
handleError(error, $t('errors.unable_to_change_album_user_role'));
}
};
</script>
<Modal title={$t('options')} onClose={() => onClose({ action: 'refreshAlbum' })} size="small">
<ModalBody>
<div class="items-center justify-center">
<div class="py-2">
<h2 class="text-gray text-sm mb-2">{$t('settings').toUpperCase()}</h2>
<div class="grid p-2 gap-y-2">
{#if order}
<SettingDropdown
title={$t('display_order')}
options={Object.values(options)}
selectedOption={options[order]}
onToggle={handleToggleOrder}
/>
{/if}
<SettingSwitch
title={$t('comments_and_likes')}
subtitle={$t('let_others_respond')}
checked={album.isActivityEnabled}
onToggle={handleToggleActivity}
/>
</div>
</div>
<div class="py-2">
<div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div>
<div class="p-2">
<button type="button" class="flex items-center gap-2" onclick={() => onClose({ action: 'shareUser' })}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div>
</div>
<div>{$t('invite_people')}</div>
</button>
<div class="flex items-center gap-2 py-2 mt-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
<div>{$t('owner')}</div>
</div>
{#each album.albumUsers as { user, role } (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
{#if role === AlbumUserRole.Viewer}
{$t('role_viewer')}
{:else}
{$t('role_editor')}
{/if}
{#if user.id !== album.ownerId}
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
{#if role === AlbumUserRole.Viewer}
<MenuOption
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Editor)}
text={$t('allow_edits')}
/>
{:else}
<MenuOption
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)}
text={$t('disallow_edits')}
/>
{/if}
<!-- Allow deletion for non-owners -->
<MenuOption onClick={() => handleRemoveUser(user)} text={$t('remove')} />
</ButtonContextMenu>
{/if}
</div>
{/each}
</div>
</div>
</div>
</ModalBody>
</Modal>

View File

@ -67,7 +67,9 @@ export const openFileUploadDialog = async (options: FileUploadParam = {}) => {
fileSelector.type = 'file'; fileSelector.type = 'file';
fileSelector.multiple = multiple; fileSelector.multiple = multiple;
fileSelector.accept = extensions.join(','); fileSelector.accept = extensions.join(',');
fileSelector.addEventListener('change', (e: Event) => { fileSelector.addEventListener(
'change',
(e: Event) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (!target.files) { if (!target.files) {
return; return;
@ -75,7 +77,9 @@ export const openFileUploadDialog = async (options: FileUploadParam = {}) => {
const files = Array.from(target.files); const files = Array.from(target.files);
resolve(fileUploadHandler({ files, albumId, replaceAssetId: assetId })); resolve(fileUploadHandler({ files, albumId, replaceAssetId: assetId }));
}); },
{ passive: true },
);
fileSelector.click(); fileSelector.click();
} catch (error) { } catch (error) {

View File

@ -4,7 +4,6 @@
import CastButton from '$lib/cast/cast-button.svelte'; import CastButton from '$lib/cast/cast-button.svelte';
import AlbumDescription from '$lib/components/album-page/album-description.svelte'; import AlbumDescription from '$lib/components/album-page/album-description.svelte';
import AlbumMap from '$lib/components/album-page/album-map.svelte'; import AlbumMap from '$lib/components/album-page/album-map.svelte';
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
import AlbumSummary from '$lib/components/album-page/album-summary.svelte'; import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
import AlbumTitle from '$lib/components/album-page/album-title.svelte'; import AlbumTitle from '$lib/components/album-page/album-title.svelte';
import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte'; import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte';
@ -38,6 +37,7 @@
import { modalManager } from '$lib/managers/modal-manager.svelte'; import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte'; import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
@ -130,27 +130,6 @@
} }
}); });
const handleToggleEnableActivity = async () => {
try {
const updateAlbum = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
isActivityEnabled: !album.isActivityEnabled,
},
});
album = { ...album, isActivityEnabled: updateAlbum.isActivityEnabled };
await refreshAlbum();
notificationController.show({
type: NotificationType.Info,
message: $t('activity_changed', { values: { enabled: album.isActivityEnabled } }),
});
} catch (error) {
handleError(error, $t('errors.cant_change_activity', { values: { enabled: album.isActivityEnabled } }));
}
};
const handleFavorite = async () => { const handleFavorite = async () => {
try { try {
await activityManager.toggleLike(); await activityManager.toggleLike();
@ -262,22 +241,6 @@
} }
}; };
const handleRemoveUser = async (userId: string, nextViewMode: AlbumPageViewMode) => {
if (userId == 'me' || userId === $user.id) {
await goto(backUrl);
return;
}
try {
await refreshAlbum();
// Dynamically set the view mode based on the passed argument
viewMode = album.albumUsers.length > 0 ? nextViewMode : AlbumPageViewMode.VIEW;
} catch (error) {
handleError(error, $t('errors.error_deleting_shared_user'));
}
};
const handleDownloadAlbum = async () => { const handleDownloadAlbum = async () => {
await downloadAlbum(album); await downloadAlbum(album);
}; };
@ -453,6 +416,29 @@
album = await getAlbumInfo({ id: album.id, withoutAssets: true }); album = await getAlbumInfo({ id: album.id, withoutAssets: true });
} }
}; };
const handleOptions = async () => {
const result = await modalManager.show(AlbumOptionsModal, { album, order: albumOrder, user: $user });
if (!result) {
return;
}
switch (result.action) {
case 'changeOrder': {
albumOrder = result.order;
break;
}
case 'shareUser': {
await handleShare();
break;
}
case 'refreshAlbum': {
await refreshAlbum();
break;
}
}
};
</script> </script>
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}> <div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
@ -697,11 +683,7 @@
text={$t('select_album_cover')} text={$t('select_album_cover')}
onClick={() => (viewMode = AlbumPageViewMode.SELECT_THUMBNAIL)} onClick={() => (viewMode = AlbumPageViewMode.SELECT_THUMBNAIL)}
/> />
<MenuOption <MenuOption icon={mdiCogOutline} text={$t('options')} onClick={handleOptions} />
icon={mdiCogOutline}
text={$t('options')}
onClick={() => (viewMode = AlbumPageViewMode.OPTIONS)}
/>
{/if} {/if}
<MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} onClick={() => handleRemoveAlbum()} /> <MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} onClick={() => handleRemoveAlbum()} />
@ -773,23 +755,6 @@
{/if} {/if}
</div> </div>
{#if viewMode === AlbumPageViewMode.OPTIONS && $user}
<AlbumOptions
{album}
order={albumOrder}
user={$user}
onChangeOrder={async (order) => {
albumOrder = order;
await setModeToView();
}}
onRemove={(userId) => handleRemoveUser(userId, AlbumPageViewMode.OPTIONS)}
onRefreshAlbum={refreshAlbum}
onClose={() => (viewMode = AlbumPageViewMode.VIEW)}
onToggleEnabledActivity={handleToggleEnableActivity}
onShowSelectSharedUser={handleShare}
/>
{/if}
<style> <style>
::placeholder { ::placeholder {
color: rgb(60, 60, 60); color: rgb(60, 60, 60);

View File

@ -20,7 +20,7 @@ const handleInstall = (event: ExtendableEvent) => {
event.waitUntil(addFilesToCache()); event.waitUntil(addFilesToCache());
}; };
sw.addEventListener('install', handleInstall); sw.addEventListener('install', handleInstall, { passive: true });
sw.addEventListener('activate', handleActivate); sw.addEventListener('activate', handleActivate, { passive: true });
sw.addEventListener('fetch', handleFetchEvent); sw.addEventListener('fetch', handleFetchEvent, { passive: true });
installBroadcastChannelListener(); installBroadcastChannelListener();