Compare commits

..

1 Commits

Author SHA1 Message Date
Yaros
ee23794625 perf(mobile): optimized album sorting 2026-01-10 11:04:33 +01:00
5 changed files with 105 additions and 79 deletions

View File

@@ -171,16 +171,8 @@ class RemoteAlbumService {
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
for (final album in albums) {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
}
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final albumIds = albums.map((e) => e.id).toList();
final assetTimestamps = await _repository.getNewestAssetTimestampForAlbums(albumIds);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
@@ -193,15 +185,8 @@ class RemoteAlbumService {
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
};
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final albumIds = albums.map((e) => e.id).toList();
final assetTimestamps = await _repository.getOldestAssetTimestampForAlbums(albumIds);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);

View File

@@ -321,26 +321,64 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}).watchSingleOrNull();
}
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
Future<Map<String, DateTime?>> getNewestAssetTimestampForAlbums(List<String> albumIds) async {
if (albumIds.isEmpty) {
return {};
}
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
final results = <String, DateTime?>{};
// Chunk calls to avoid SQLite limit (default 999 variables)
const chunkSize = 900;
for (var i = 0; i < albumIds.length; i += chunkSize) {
final end = (i + chunkSize < albumIds.length) ? i + chunkSize : albumIds.length;
final subList = albumIds.sublist(i, end);
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.isIn(subList))
..addColumns([_db.remoteAlbumAssetEntity.albumId, _db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
])
..groupBy([_db.remoteAlbumAssetEntity.albumId]);
final rows = await query.get();
for (final row in rows) {
results[row.read(_db.remoteAlbumAssetEntity.albumId)!] = row.read(_db.remoteAssetEntity.localDateTime.max());
}
}
return results;
}
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
Future<Map<String, DateTime?>> getOldestAssetTimestampForAlbums(List<String> albumIds) async {
if (albumIds.isEmpty) {
return {};
}
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
final results = <String, DateTime?>{};
// Chunk calls to avoid SQLite limit (default 999 variables)
const chunkSize = 900;
for (var i = 0; i < albumIds.length; i += chunkSize) {
final end = (i + chunkSize < albumIds.length) ? i + chunkSize : albumIds.length;
final subList = albumIds.sublist(i, end);
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.isIn(subList))
..addColumns([_db.remoteAlbumAssetEntity.albumId, _db.remoteAssetEntity.localDateTime.min()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
])
..groupBy([_db.remoteAlbumAssetEntity.albumId]);
final rows = await query.get();
for (final row in rows) {
results[row.read(_db.remoteAlbumAssetEntity.albumId)!] = row.read(_db.remoteAssetEntity.localDateTime.min());
}
}
return results;
}
Future<int> getCount() {

View File

@@ -18,30 +18,34 @@ void main() {
mockAlbumApiRepo = MockDriftAlbumApiRepository();
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the newest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2023, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2023, 2, 1));
when(() => mockRemoteAlbumRepo.getNewestAssetTimestampForAlbums(any())).thenAnswer((invocation) async {
final albumIds = invocation.positionalArguments[0] as List<String>;
final result = <String, DateTime?>{};
for (final id in albumIds) {
if (id == '1') {
result[id] = DateTime(2023, 1, 1);
} else if (id == '2') {
result[id] = DateTime(2023, 2, 1);
} else {
result[id] = DateTime.fromMillisecondsSinceEpoch(0);
}
}
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
return result;
});
when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the oldest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2019, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2019, 2, 1));
when(() => mockRemoteAlbumRepo.getOldestAssetTimestampForAlbums(any())).thenAnswer((invocation) async {
final albumIds = invocation.positionalArguments[0] as List<String>;
final result = <String, DateTime?>{};
for (final id in albumIds) {
if (id == '1') {
result[id] = DateTime(2019, 1, 1);
} else if (id == '2') {
result[id] = DateTime(2019, 2, 1);
} else {
result[id] = DateTime.fromMillisecondsSinceEpoch(0);
}
}
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
return result;
});
});

View File

@@ -8,7 +8,6 @@
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import EditAction from '$lib/components/asset-viewer/actions/edit-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
@@ -21,7 +20,7 @@
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.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 { AppRoute, ProjectionType } from '$lib/constants';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
@@ -73,7 +72,7 @@
onUndoDelete?: OnUndoDelete;
onRunJob: (name: AssetJobName) => void;
onPlaySlideshow: () => void;
onEdit: () => void;
// onEdit: () => void;
onClose?: () => void;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
@@ -93,7 +92,7 @@
onRunJob,
onPlaySlideshow,
onClose,
onEdit,
// onEdit,
playOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
@@ -116,17 +115,18 @@
const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } =
$derived(getAssetActions($t, asset));
let showEditorButton = $derived(
isOwner &&
asset.type === AssetTypeEnum.Image &&
!(
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
) &&
!(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) &&
!(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) &&
!asset.livePhotoVideoId,
);
// TODO: Enable when edits are ready for release
// let showEditorButton = $derived(
// isOwner &&
// asset.type === AssetTypeEnum.Image &&
// !(
// asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
// (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
// ) &&
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) &&
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) &&
// !asset.livePhotoVideoId,
// );
</script>
<CommandPaletteDefaultProvider
@@ -179,9 +179,9 @@
<RatingAction {asset} {onAction} />
{/if}
{#if showEditorButton}
<!-- {#if showEditorButton}
<EditAction onAction={onEdit} />
{/if}
{/if} -->
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />

View File

@@ -255,12 +255,12 @@
});
};
const showEditor = () => {
if (assetViewerManager.isShowActivityPanel) {
assetViewerManager.isShowActivityPanel = false;
}
isShowEditor = !isShowEditor;
};
// const showEditor = () => {
// if (assetViewerManager.isShowActivityPanel) {
// assetViewerManager.isShowActivityPanel = false;
// }
// isShowEditor = !isShowEditor;
// };
const handleRunJob = async (name: AssetJobName) => {
try {
@@ -431,7 +431,6 @@
onCopyImage={copyImage}
preAction={handlePreAction}
onAction={handleAction}
onEdit={showEditor}
{onUndoDelete}
onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}