diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 1c645b0e4..f36f733a1 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -32,7 +32,7 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; import 'package:leak_tracker/leak_tracker.dart'; -enum SourceScope { none, album, full } +typedef SourceScope = Set?; mixin SourceBase { EventBus get eventBus; @@ -63,6 +63,8 @@ mixin SourceBase { } abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, PlaceMixin, StateMixin, LocationMixin, TagMixin, TrashMixin { + static const fullScope = {}; + CollectionSource() { if (kFlutterMemoryAllocationsEnabled) { LeakTracking.dispatchObjectCreated( @@ -428,11 +430,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place eventBus.fire(EntryMovedEvent(MoveType.move, movedEntries)); } - SourceScope get scope => SourceScope.none; + SourceScope get loadedScope; + + SourceScope get targetScope; Future init({ + required SourceScope scope, AnalysisController? analysisController, - AlbumFilter? albumFilter, bool loadTopEntriesFirst = false, }); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index e2184f44d..fa2f44df6 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -21,38 +21,40 @@ class MediaStoreSource extends CollectionSource { final Debouncer _changeDebouncer = Debouncer(delay: ADurations.mediaContentChangeDebounceDelay); final Set _changedUris = {}; int? _lastGeneration; - SourceScope _scope = SourceScope.none; + SourceScope _loadedScope, _targetScope; bool _canAnalyze = true; @override set canAnalyze(bool enabled) => _canAnalyze = enabled; @override - SourceScope get scope => _scope; + SourceScope get loadedScope => _loadedScope; + + @override + SourceScope get targetScope => _targetScope; @override Future init({ + required SourceScope scope, AnalysisController? analysisController, - AlbumFilter? albumFilter, bool loadTopEntriesFirst = false, }) async { - await reportService.log('$runtimeType init album=${albumFilter?.album}'); - if (_scope == SourceScope.none) { - await _loadEssentials(); - } - if (_scope != SourceScope.full) { - _scope = albumFilter != null ? SourceScope.album : SourceScope.full; - } + _targetScope = scope; + await reportService.log('$runtimeType init target scope=$scope'); + await _loadEssentials(); addDirectories(albums: settings.pinnedFilters.whereType().map((v) => v.album).toSet()); await updateGeneration(); unawaited(_loadEntries( analysisController: analysisController, - directory: albumFilter?.album, loadTopEntriesFirst: loadTopEntriesFirst, )); } + bool _areEssentialsLoaded = false; + Future _loadEssentials() async { + if (_areEssentialsLoaded) return; + final stopwatch = Stopwatch()..start(); state = SourceState.loading; await localMediaDb.init(); @@ -63,20 +65,19 @@ class MediaStoreSource extends CollectionSource { if (currentTimeZoneOffset != null) { final catalogTimeZoneOffset = settings.catalogTimeZoneRawOffsetMillis; if (currentTimeZoneOffset != catalogTimeZoneOffset) { - // clear catalog metadata to get correct date/times when moving to a different time zone - debugPrint('$runtimeType clear catalog metadata to get correct date/times'); + unawaited(reportService.log('Time zone offset change: $currentTimeZoneOffset -> $catalogTimeZoneOffset. Clear catalog metadata to get correct date/times.')); await localMediaDb.clearDates(); await localMediaDb.clearCatalogMetadata(); settings.catalogTimeZoneRawOffsetMillis = currentTimeZoneOffset; } } await loadDates(); + _areEssentialsLoaded = true; debugPrint('$runtimeType load essentials complete in ${stopwatch.elapsed.inMilliseconds}ms'); } Future _loadEntries({ AnalysisController? analysisController, - String? directory, required bool loadTopEntriesFirst, }) async { unawaited(reportService.log('$runtimeType load (known) start')); @@ -84,6 +85,9 @@ class MediaStoreSource extends CollectionSource { state = SourceState.loading; clearEntries(); + final scopeAlbumFilters = _targetScope?.whereType(); + final scopeDirectory = scopeAlbumFilters != null && scopeAlbumFilters.length == 1 ? scopeAlbumFilters.first.album : null; + final Set topEntries = {}; if (loadTopEntriesFirst) { final topIds = settings.topEntryIds?.toSet(); @@ -95,7 +99,7 @@ class MediaStoreSource extends CollectionSource { } debugPrint('$runtimeType load ${stopwatch.elapsed} fetch known entries'); - final knownEntries = await localMediaDb.loadEntries(origin: EntryOrigins.mediaStoreContent, directory: directory); + final knownEntries = await localMediaDb.loadEntries(origin: EntryOrigins.mediaStoreContent, directory: scopeDirectory); final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet(); debugPrint('$runtimeType load ${stopwatch.elapsed} check obsolete entries'); @@ -114,18 +118,20 @@ class MediaStoreSource extends CollectionSource { // add entries without notifying, so that the collection is not refreshed // with items that may be hidden right away because of their metadata addEntries(knownEntries, notify: false); + // but use album notification without waiting for cataloguing + // so that it is more reactive when picking an album in view mode + notifyAlbumsChanged(); - await _loadVaultEntries(directory); + await _loadVaultEntries(scopeDirectory); debugPrint('$runtimeType load ${stopwatch.elapsed} load metadata'); - if (directory != null) { + if (scopeDirectory != null) { final ids = knownLiveEntries.map((entry) => entry.id).toSet(); await loadCatalogMetadata(ids: ids); await loadAddresses(ids: ids); } else { await loadCatalogMetadata(); await loadAddresses(); - updateDerivedFilters(); // trash await loadTrashDetails(); @@ -139,6 +145,7 @@ class MediaStoreSource extends CollectionSource { onError: (error) => debugPrint('failed to evict expired trash error=$error'), )); } + updateDerivedFilters(); // clean up obsolete entries if (removedEntries.isNotEmpty) { @@ -146,13 +153,14 @@ class MediaStoreSource extends CollectionSource { await localMediaDb.removeIds(removedEntries.map((entry) => entry.id).toSet()); } + _loadedScope = _targetScope; unawaited(reportService.log('$runtimeType load (known) done in ${stopwatch.elapsed.inSeconds}s for ${knownEntries.length} known, ${removedEntries.length} removed')); if (_canAnalyze) { // it can discover new entries only if it can analyze them await _loadNewEntries( analysisController: analysisController, - directory: directory, + directory: scopeDirectory, knownLiveEntries: knownLiveEntries, knownDateByContentId: knownDateByContentId, ); @@ -252,7 +260,7 @@ class MediaStoreSource extends CollectionSource { // sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg` @override Future> refreshUris(Set changedUris, {AnalysisController? analysisController}) async { - if (_scope == SourceScope.none || !canRefresh || !isReady) return changedUris; + if (!canRefresh || !_areEssentialsLoaded || !isReady) return changedUris; state = SourceState.loading; diff --git a/lib/services/analysis_service.dart b/lib/services/analysis_service.dart index c56134593..693199a96 100644 --- a/lib/services/analysis_service.dart +++ b/lib/services/analysis_service.dart @@ -5,6 +5,7 @@ import 'package:aves/l10n/l10n.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/android_file_utils.dart'; @@ -148,7 +149,7 @@ class Analyzer with WidgetsBindingObserver { settings.systemLocalesFallback = await deviceService.getLocales(); _l10n = await AppLocalizations.delegate.load(settings.appliedLocale); _serviceStateNotifier.value = AnalyzerState.running; - await _source.init(analysisController: _controller); + await _source.init(scope: CollectionSource.fullScope, analysisController: _controller); _notificationUpdateTimer = Timer.periodic(notificationUpdateInterval, (_) async { if (!isRunning) return; diff --git a/lib/widget_common.dart b/lib/widget_common.dart index f9b357c85..c915fecd9 100644 --- a/lib/widget_common.dart +++ b/lib/widget_common.dart @@ -97,7 +97,7 @@ Future _getWidgetEntry(int widgetId, bool reuseEntry) async { } }); source.canAnalyze = false; - await source.init(); + await source.init(scope: filters); await readyCompleter.future; final entries = CollectionLens(source: source, filters: filters).sortedEntries; diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index c6f3f6823..3b15aab04 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -685,11 +685,9 @@ class _AvesAppState extends State with WidgetsBindingObserver { Future _onAnalysisCompletion() async { debugPrint('Analysis completed'); - if (_mediaStoreSource.scope != SourceScope.none) { - await _mediaStoreSource.loadCatalogMetadata(); - await _mediaStoreSource.loadAddresses(); - _mediaStoreSource.updateDerivedFilters(); - } + await _mediaStoreSource.loadCatalogMetadata(); + await _mediaStoreSource.loadAddresses(); + _mediaStoreSource.updateDerivedFilters(); } void _onError(String? error) => reportService.recordError(error, null); diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index f2130591b..44b2b2899 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -498,7 +498,7 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge _checkingStoragePermission = false; _isStoragePermissionGranted.then((granted) { if (granted) { - widget.collection.source.init(); + widget.collection.source.init(scope: CollectionSource.fullScope); } }); } diff --git a/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart b/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart index 75f001ad9..6e1a4a02d 100644 --- a/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart +++ b/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart @@ -35,11 +35,11 @@ Future pickAlbum({ required MoveType? moveType, }) async { final source = context.read(); - if (source.scope != SourceScope.full) { + if (source.targetScope != CollectionSource.fullScope) { await reportService.log('Complete source initialization to pick album'); // source may not be fully initialized in view mode source.canAnalyze = true; - await source.init(); + await source.init(scope: CollectionSource.fullScope); } final filter = await Navigator.maybeOf(context)?.push( MaterialPageRoute( diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index f65d0d604..8d48b5323 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -222,16 +222,17 @@ class _HomePageState extends State { unawaited(GlobalSearch.registerCallback()); unawaited(AnalysisService.registerCallback()); final source = context.read(); - if (source.scope != SourceScope.full) { - await reportService.log('Initialize source (init state=${source.scope.name}) to start app with mode=$appMode'); + if (source.loadedScope != CollectionSource.fullScope) { + await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}'); final loadTopEntriesFirst = settings.homePage == HomePageSetting.collection && settings.homeCustomCollection.isEmpty; - await source.init(loadTopEntriesFirst: loadTopEntriesFirst); + source.canAnalyze = true; + await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); } case AppMode.screenSaver: await reportService.log('Initialize source to start screen saver'); final source = context.read(); source.canAnalyze = false; - await source.init(); + await source.init(scope: settings.screenSaverCollectionFilters); case AppMode.view: if (_isViewerSourceable(_viewerEntry) && _secureUris == null) { final directory = _viewerEntry?.directory; @@ -240,7 +241,7 @@ class _HomePageState extends State { await reportService.log('Initialize source to view item in directory $directory'); final source = context.read(); source.canAnalyze = false; - await source.init(albumFilter: AlbumFilter(directory, null)); + await source.init(scope: {AlbumFilter(directory, null)}); } } else { await _initViewerEssentials(); @@ -305,38 +306,38 @@ class _HomePageState extends State { CollectionLens? collection; final source = context.read(); - if (source.scope != SourceScope.none) { - final album = viewerEntry.directory; - if (album != null) { - // wait for collection to pass the `loading` state - final completer = Completer(); - void _onSourceStateChanged() { - if (source.state != SourceState.loading) { - source.stateNotifier.removeListener(_onSourceStateChanged); - completer.complete(); - } + final album = viewerEntry.directory; + if (album != null) { + // wait for collection to pass the `loading` state + final completer = Completer(); + final stateNotifier = source.stateNotifier; + void _onSourceStateChanged() { + if (stateNotifier.value != SourceState.loading) { + stateNotifier.removeListener(_onSourceStateChanged); + completer.complete(); } + } - source.stateNotifier.addListener(_onSourceStateChanged); - await completer.future; + stateNotifier.addListener(_onSourceStateChanged); + _onSourceStateChanged(); + await completer.future; - collection = CollectionLens( - source: source, - filters: {AlbumFilter(album, source.getAlbumDisplayName(context, album))}, - listenToSource: false, - // if we group bursts, opening a burst sub-entry should: - // - identify and select the containing main entry, - // - select the sub-entry in the Viewer page. - stackBursts: false, - ); - final viewerEntryPath = viewerEntry.path; - final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); - if (collectionEntry != null) { - viewerEntry = collectionEntry; - } else { - debugPrint('collection does not contain viewerEntry=$viewerEntry'); - collection = null; - } + collection = CollectionLens( + source: source, + filters: {AlbumFilter(album, source.getAlbumDisplayName(context, album))}, + listenToSource: false, + // if we group bursts, opening a burst sub-entry should: + // - identify and select the containing main entry, + // - select the sub-entry in the Viewer page. + stackBursts: false, + ); + final viewerEntryPath = viewerEntry.path; + final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); + if (collectionEntry != null) { + viewerEntry = collectionEntry; + } else { + debugPrint('collection does not contain viewerEntry=$viewerEntry'); + collection = null; } } diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 52b500e8d..945ee8b66 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -442,9 +442,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix showFeedback(context, FeedbackType.warn, l10n.genericFailureFeedback); } else { final source = context.read(); - if (source.scope != SourceScope.none) { - await source.removeEntries({targetEntry.uri}, includeTrash: true); - } + await source.removeEntries({targetEntry.uri}, includeTrash: true); EntryDeletedNotification({targetEntry}).dispatch(context); } } diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 2c21c62c9..e799a9cae 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -91,7 +91,7 @@ void main() { readyCompleter.complete(); } }); - await source.init(); + await source.init(scope: CollectionSource.fullScope); await readyCompleter.future; return source; } @@ -107,9 +107,9 @@ void main() { (mediaFetchService as FakeMediaFetchService).entries = {refreshEntry}; final source = MediaStoreSource(); - unawaited(source.init()); + unawaited(source.init(scope: CollectionSource.fullScope)); await Future.delayed(const Duration(milliseconds: 10)); - expect(source.scope, SourceScope.full); + expect(source.targetScope, CollectionSource.fullScope); await source.refreshUris({refreshEntry.uri}); await Future.delayed(const Duration(seconds: 1));