You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-07-16 07:24:40 +02:00
feat(mobile): sqlite timeline (#19197)
* wip: timeline * more segment extensions * added scrubber * refactor: timeline state * more refactors * fix scrubber segments * added remote thumb & thumbhash provider * feat: merged view * scrub / merged asset fixes * rename stuff & add tile indicators * fix local album timeline query * ignore hidden assets during sync * ignore recovered assets during sync * old scrubber * add video indicator * handle groupBy * handle partner inTimeline * show duration * reduce widget nesting in thumb tile * merge main * chore: extend cacheExtent * ignore touch events on scrub label when not visible * scrub label ignore events and hide immediately * auto reload on sync * refactor image providers * throttle db updates --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
10
mobile/lib/domain/interfaces/asset_media.interface.dart
Normal file
10
mobile/lib/domain/interfaces/asset_media.interface.dart
Normal 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),
|
||||
});
|
||||
}
|
27
mobile/lib/domain/interfaces/timeline.interface.dart
Normal file
27
mobile/lib/domain/interfaces/timeline.interface.dart
Normal 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,
|
||||
});
|
||||
}
|
@ -11,6 +11,7 @@ enum AssetVisibility {
|
||||
class Asset extends BaseAsset {
|
||||
final String id;
|
||||
final String? localId;
|
||||
final String? thumbHash;
|
||||
final AssetVisibility visibility;
|
||||
|
||||
const Asset({
|
||||
@ -25,9 +26,14 @@ class Asset extends BaseAsset {
|
||||
super.height,
|
||||
super.durationInSeconds,
|
||||
super.isFavorite = false,
|
||||
this.thumbHash,
|
||||
this.visibility = AssetVisibility.timeline,
|
||||
});
|
||||
|
||||
@override
|
||||
AssetState get storage =>
|
||||
localId == null ? AssetState.remote : AssetState.merged;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
@ -41,6 +47,7 @@ class Asset extends BaseAsset {
|
||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||
localId: ${localId ?? "<NA>"},
|
||||
isFavorite: $isFavorite,
|
||||
thumbHash: ${thumbHash ?? "<NA>"},
|
||||
visibility: $visibility,
|
||||
}''';
|
||||
}
|
||||
@ -52,10 +59,15 @@ class Asset extends BaseAsset {
|
||||
return super == other &&
|
||||
id == other.id &&
|
||||
localId == other.localId &&
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
super.hashCode ^ id.hashCode ^ localId.hashCode ^ visibility.hashCode;
|
||||
super.hashCode ^
|
||||
id.hashCode ^
|
||||
localId.hashCode ^
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode;
|
||||
}
|
||||
|
@ -9,6 +9,12 @@ enum AssetType {
|
||||
audio,
|
||||
}
|
||||
|
||||
enum AssetState {
|
||||
local,
|
||||
remote,
|
||||
merged,
|
||||
}
|
||||
|
||||
sealed class BaseAsset {
|
||||
final String name;
|
||||
final String? checksum;
|
||||
@ -32,6 +38,10 @@ sealed class BaseAsset {
|
||||
this.isFavorite = false,
|
||||
});
|
||||
|
||||
bool get isImage => type == AssetType.image;
|
||||
bool get isVideo => type == AssetType.video;
|
||||
AssetState get storage;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''BaseAsset {
|
||||
|
@ -18,6 +18,10 @@ class LocalAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
});
|
||||
|
||||
@override
|
||||
AssetState get storage =>
|
||||
remoteId == null ? AssetState.local : AssetState.merged;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''LocalAsset {
|
||||
|
12
mobile/lib/domain/models/setting.model.dart
Normal file
12
mobile/lib/domain/models/setting.model.dart
Normal 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;
|
||||
}
|
40
mobile/lib/domain/models/timeline.model.dart
Normal file
40
mobile/lib/domain/models/timeline.model.dart
Normal 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;
|
||||
}
|
19
mobile/lib/domain/services/setting.service.dart
Normal file
19
mobile/lib/domain/services/setting.service.dart
Normal 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);
|
||||
}
|
126
mobile/lib/domain/services/timeline.service.dart
Normal file
126
mobile/lib/domain/services/timeline.service.dart
Normal 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user