feat(mobile): lazy loading of assets (#2413)

This commit is contained in:
Fynn Petersen-Frey
2023-05-17 19:36:02 +02:00
committed by GitHub
parent 93863b0629
commit 0dde76bbbc
54 changed files with 1494 additions and 2328 deletions

View File

@@ -1,4 +1,5 @@
import 'package:flutter/cupertino.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
@@ -34,10 +35,10 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>();
List<Asset> _sortedAssets = [];
RenderList _renderList = RenderList.empty();
@ignore
List<Asset> get sortedAssets => _sortedAssets;
RenderList get renderList => _renderList;
@ignore
bool get isRemote => remoteId != null;
@@ -69,8 +70,14 @@ class Album {
return name.join(' ');
}
Future<void> loadSortedAssets() async {
_sortedAssets = await assets.filter().sortByFileCreatedAt().findAll();
Stream<void> watchRenderList(GroupAssetsBy groupAssetsBy) async* {
final query = assets.filter().sortByFileCreatedAt();
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
yield _renderList;
await for (final _ in query.watchLazy()) {
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
yield _renderList;
}
}
@override

View File

@@ -225,7 +225,6 @@ class Asset {
a.isLocal && !isLocal ||
width == null && a.width != null ||
height == null && a.height != null ||
exifInfo == null && a.exifInfo != null ||
livePhotoVideoId == null && a.livePhotoVideoId != null ||
!isRemote && a.isRemote && isFavorite != a.isFavorite ||
!isRemote && a.isRemote && isArchived != a.isArchived;

View File

@@ -114,6 +114,45 @@ class ExifInfo {
country: country ?? this.country,
description: description ?? this.description,
);
@override
bool operator ==(other) {
if (other is! ExifInfo) return false;
return id == other.id &&
fileSize == other.fileSize &&
make == other.make &&
model == other.model &&
lens == other.lens &&
f == other.f &&
mm == other.mm &&
iso == other.iso &&
exposureSeconds == other.exposureSeconds &&
lat == other.lat &&
long == other.long &&
city == other.city &&
state == other.state &&
country == other.country &&
description == other.description;
}
@override
@ignore
int get hashCode =>
id.hashCode ^
fileSize.hashCode ^
make.hashCode ^
model.hashCode ^
lens.hashCode ^
f.hashCode ^
mm.hashCode ^
iso.hashCode ^
exposureSeconds.hashCode ^
lat.hashCode ^
long.hashCode ^
city.hashCode ^
state.hashCode ^
country.hashCode ^
description.hashCode;
}
double? _exposureTimeToSeconds(String? s) {

View File

@@ -35,6 +35,10 @@ class Store {
return value;
}
/// Watches a specific key for changes
static Stream<T?> watch<T>(StoreKey<T> key) =>
_db.storeValues.watchObject(key.id).map((e) => e?._extract(key));
/// Returns the stored value for the given key (possibly null)
static T? tryGet<T>(StoreKey<T> key) => _cache[key.id];

View File

@@ -3,18 +3,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:intl/intl.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -22,72 +18,23 @@ import 'package:photo_manager/photo_manager.dart';
/// State does not contain archived assets.
/// Use database provider if you want to access the isArchived assets
class AssetsState {
final List<Asset> allAssets;
final RenderList? renderList;
AssetsState(this.allAssets, {this.renderList});
Future<AssetsState> withRenderDataStructure(
AssetGridLayoutParameters layout,
) async {
return AssetsState(
allAssets,
renderList: await RenderList.fromAssets(
allAssets,
layout,
),
);
}
AssetsState withAdditionalAssets(List<Asset> toAdd) {
return AssetsState([...allAssets, ...toAdd]);
}
static AssetsState fromAssetList(List<Asset> assets) {
return AssetsState(assets);
}
static AssetsState empty() {
return AssetsState([]);
}
}
class AssetsState {}
class AssetNotifier extends StateNotifier<AssetsState> {
final AssetService _assetService;
final AppSettingsService _settingsService;
final AlbumService _albumService;
final SyncService _syncService;
final Isar _db;
final log = Logger('AssetNotifier');
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
final AsyncMutex _stateUpdateLock = AsyncMutex();
AssetNotifier(
this._assetService,
this._settingsService,
this._albumService,
this._syncService,
this._db,
) : super(AssetsState.fromAssetList([]));
Future<void> _updateAssetsState(List<Asset> newAssetList) async {
final layout = AssetGridLayoutParameters(
_settingsService.getSetting(AppSettingsEnum.tilesPerRow),
_settingsService.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
);
state = await AssetsState.fromAssetList(newAssetList)
.withRenderDataStructure(layout);
}
// Just a little helper to trigger a rebuild of the state object
Future<void> rebuildAssetGridDataStructure() async {
await _updateAssetsState(state.allAssets);
}
) : super(AssetsState());
Future<void> getAllAsset({bool clear = false}) async {
if (_getAllAssetInProgress || _deleteInProgress) {
@@ -97,79 +44,32 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final stopwatch = Stopwatch()..start();
try {
_getAllAssetInProgress = true;
final User me = Store.get(StoreKey.currentUser);
if (clear) {
await clearAssetsAndAlbums(_db);
log.info("Manual refresh requested, cleared assets and albums from db");
} else if (_stateUpdateLock.enqueued <= 1) {
final int cachedCount = await _userAssetQuery(me.isarId).count();
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
await _stateUpdateLock.run(
() async => _updateAssetsState(await _getUserAssets(me.isarId)),
);
log.info(
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
}
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
debugPrint("newRemote: $newRemote, newLocal: $newLocal");
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (!newRemote &&
!newLocal &&
state.allAssets.length == await _userAssetQuery(me.isarId).count()) {
log.info("state is already up-to-date");
return;
}
stopwatch.reset();
if (_stateUpdateLock.enqueued <= 1) {
_stateUpdateLock.run(() async {
final assets = await _getUserAssets(me.isarId);
if (!const ListEquality().equals(assets, state.allAssets)) {
log.info("setting new asset state");
await _updateAssetsState(assets);
}
});
}
} finally {
_getAllAssetInProgress = false;
}
}
Future<List<Asset>> _getUserAssets(int userId) =>
_userAssetQuery(userId).sortByFileCreatedAtDesc().findAll();
QueryBuilder<Asset, Asset, QAfterFilterCondition> _userAssetQuery(
int userId,
) =>
_db.assets.filter().ownerIdEqualTo(userId).isArchivedEqualTo(false);
Future<void> clearAllAsset() {
state = AssetsState.empty();
return clearAssetsAndAlbums(_db);
}
Future<void> onNewAssetUploaded(Asset newAsset) async {
final bool ok = await _syncService.syncNewAssetToDb(newAsset);
if (ok && _stateUpdateLock.enqueued <= 1) {
// run this sequentially if there is at most 1 other task waiting
await _stateUpdateLock.run(() async {
final userId = Store.get(StoreKey.currentUser).isarId;
final assets = await _getUserAssets(userId);
await _updateAssetsState(assets);
});
}
// eTag on device is not valid after partially modifying the assets
Store.delete(StoreKey.assetETag);
await _syncService.syncNewAssetToDb(newAsset);
}
Future<void> deleteAssets(Set<Asset> deleteAssets) async {
_deleteInProgress = true;
try {
_updateAssetsState(
state.allAssets.whereNot(deleteAssets.contains).toList(),
);
final localDeleted = await _deleteLocalAssets(deleteAssets);
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
@@ -201,7 +101,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
if (local.isNotEmpty) {
try {
await PhotoManager.editor.deleteWithIds(local);
return await PhotoManager.editor.deleteWithIds(local);
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}
@@ -220,53 +120,25 @@ class AssetNotifier extends StateNotifier<AssetsState> {
.map((a) => a.id);
}
Future<bool> toggleFavorite(Asset asset, bool status) async {
final newAsset = await _assetService.changeFavoriteStatus(asset, status);
if (newAsset == null) {
log.severe("Change favorite status failed for asset ${asset.id}");
return asset.isFavorite;
Future<void> toggleFavorite(List<Asset> assets, bool status) async {
final newAssets = await _assetService.changeFavoriteStatus(assets, status);
for (Asset? newAsset in newAssets) {
if (newAsset == null) {
log.severe("Change favorite status failed for asset");
continue;
}
}
final index = state.allAssets.indexWhere((a) => asset.id == a.id);
if (index != -1) {
state.allAssets[index] = newAsset;
_updateAssetsState(state.allAssets);
}
return newAsset.isFavorite;
}
Future<void> toggleArchive(Iterable<Asset> assets, bool status) async {
final newAssets = await Future.wait(
assets.map((a) => _assetService.changeArchiveStatus(a, status)),
);
Future<void> toggleArchive(List<Asset> assets, bool status) async {
final newAssets = await _assetService.changeArchiveStatus(assets, status);
int i = 0;
bool unArchived = false;
for (Asset oldAsset in assets) {
final newAsset = newAssets[i++];
if (newAsset == null) {
log.severe("Change archive status failed for asset ${oldAsset.id}");
continue;
}
final index = state.allAssets.indexWhere((a) => oldAsset.id == a.id);
if (newAsset.isArchived) {
// remove from state
if (index != -1) {
state.allAssets.removeAt(index);
}
} else {
// add to state is difficult because the list is sorted
unArchived = true;
}
}
if (unArchived) {
final User me = Store.get(StoreKey.currentUser);
await _stateUpdateLock.run(
() async => _updateAssetsState(await _getUserAssets(me.isarId)),
);
} else {
_updateAssetsState(state.allAssets);
}
}
}
@@ -274,26 +146,53 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
);
});
final assetGroupByMonthYearProvider = StateProvider((ref) {
// TODO: remove `where` once temporary workaround is no longer needed (to only
// allow remote assets to be added to album). Keep `toList()` as to NOT sort
// the original list/state
final assets =
ref.watch(assetProvider).allAssets.where((e) => e.isRemote).toList();
assets.sortByCompare<DateTime>(
(e) => e.fileCreatedAt,
(a, b) => b.compareTo(a),
);
return assets.groupListsBy(
(element) => DateFormat('MMMM, y').format(element.fileCreatedAt.toLocal()),
);
final assetDetailProvider =
StreamProvider.autoDispose.family<Asset, Asset>((ref, asset) async* {
yield await ref.watch(assetServiceProvider).loadExif(asset);
final db = ref.watch(dbProvider);
await for (final a in db.assets.watchObject(asset.id)) {
if (a != null) yield await ref.watch(assetServiceProvider).loadExif(a);
}
});
final assetsProvider = StreamProvider.autoDispose<RenderList>((ref) async* {
final query = ref
.watch(dbProvider)
.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.isArchivedEqualTo(false)
.sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
}
});
final remoteAssetsProvider =
StreamProvider.autoDispose<RenderList>((ref) async* {
final query = ref
.watch(dbProvider)
.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
}
});

View File

@@ -97,15 +97,18 @@ class AssetService {
/// the exif info from the server (remote assets only)
Future<Asset> loadExif(Asset a) async {
a.exifInfo ??= await _db.exifInfos.get(a.id);
if (a.exifInfo?.iso == null) {
// fileSize is always filled on the server but not set on client
if (a.exifInfo?.fileSize == null) {
if (a.isRemote) {
final dto = await _apiService.assetApi.getAssetById(a.remoteId!);
if (dto != null && dto.exifInfo != null) {
a.exifInfo = Asset.remote(dto).exifInfo!.copyWith(id: a.id);
if (a.isInDb) {
_db.writeTxn(() => a.put(_db));
} else {
debugPrint("[loadExif] parameter Asset is not from DB!");
final newExif = Asset.remote(dto).exifInfo!.copyWith(id: a.id);
if (newExif != a.exifInfo) {
if (a.isInDb) {
_db.writeTxn(() => a.put(_db));
} else {
debugPrint("[loadExif] parameter Asset is not from DB!");
}
}
}
} else {
@@ -115,27 +118,39 @@ class AssetService {
return a;
}
Future<Asset?> updateAsset(
Asset asset,
Future<List<Asset?>> updateAssets(
List<Asset> assets,
UpdateAssetDto updateAssetDto,
) async {
final dto =
await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto);
if (dto != null) {
final updated = asset.updatedCopy(Asset.remote(dto));
if (updated.isInDb) {
await _db.writeTxn(() => updated.put(_db));
final List<AssetResponseDto?> dtos = await Future.wait(
assets.map(
(a) => _apiService.assetApi.updateAsset(a.remoteId!, updateAssetDto),
),
);
bool allInDb = true;
for (int i = 0; i < assets.length; i++) {
final dto = dtos[i], old = assets[i];
if (dto != null) {
final remote = Asset.remote(dto);
if (old.canUpdate(remote)) {
assets[i] = old.updatedCopy(remote);
}
allInDb &= assets[i].isInDb;
}
return updated;
}
return null;
final toUpdate = allInDb ? assets : assets.where((e) => e.isInDb).toList();
await _syncService.upsertAssetsWithExif(toUpdate);
return assets;
}
Future<Asset?> changeFavoriteStatus(Asset asset, bool isFavorite) {
return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite));
Future<List<Asset?>> changeFavoriteStatus(
List<Asset> assets,
bool isFavorite,
) {
return updateAssets(assets, UpdateAssetDto(isFavorite: isFavorite));
}
Future<Asset?> changeArchiveStatus(Asset asset, bool isArchive) {
return updateAsset(asset, UpdateAssetDto(isArchived: isArchive));
Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool isArchive) {
return updateAssets(assets, UpdateAssetDto(isArchived: isArchive));
}
}

View File

@@ -172,7 +172,7 @@ class SyncService {
final idsToDelete = diff.third.map((e) => e.id).toList();
try {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await _upsertAssetsWithExif(diff.first + diff.second);
await upsertAssetsWithExif(diff.first + diff.second);
} on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db: $e");
}
@@ -272,7 +272,7 @@ class SyncService {
// for shared album: put missing album assets into local DB
final resultPair = await _linkWithExistingFromDb(toAdd);
await _upsertAssetsWithExif(resultPair.second);
await upsertAssetsWithExif(resultPair.second);
final assetsToLink = resultPair.first + resultPair.second;
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
@@ -329,7 +329,7 @@ class SyncService {
// put missing album assets into local DB
final result = await _linkWithExistingFromDb(dto.getAssets());
existing.addAll(result.first);
await _upsertAssetsWithExif(result.second);
await upsertAssetsWithExif(result.second);
final Album a = await Album.remote(dto);
await _db.writeTxn(() => _db.albums.store(a));
@@ -540,7 +540,7 @@ class SyncService {
_log.info(
"${result.first.length} assets already existed in DB, to upsert ${result.second.length}",
);
await _upsertAssetsWithExif(result.second);
await upsertAssetsWithExif(result.second);
existing.addAll(result.first);
a.assets.addAll(result.first);
a.assets.addAll(result.second);
@@ -600,7 +600,7 @@ class SyncService {
}
/// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> _upsertAssetsWithExif(List<Asset> assets) async {
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) {
return;
}

View File

@@ -21,19 +21,19 @@ class ControlBoxButton extends StatelessWidget {
Key? key,
required this.label,
required this.iconData,
required this.onPressed,
this.onPressed,
}) : super(key: key);
final String label;
final IconData iconData;
final Function onPressed;
final void Function()? onPressed;
@override
Widget build(BuildContext context) {
return MaterialButton(
padding: const EdgeInsets.all(10),
shape: const CircleBorder(),
onPressed: () => onPressed(),
onPressed: onPressed,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,