diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 9108e5de..5ffe598f 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -38,7 +38,7 @@ class MainActivity: FlutterFragmentActivity() { "com.zarz.spotiflac/download_progress_stream" private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream" - private val STREAM_POLLING_INTERVAL_MS = 800L + private val STREAM_POLLING_INTERVAL_MS = 1200L private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var pendingSafTreeResult: MethodChannel.Result? = null private val safScanLock = Any() @@ -469,6 +469,32 @@ class MainActivity: FlutterFragmentActivity() { lastLibraryScanProgressPayload = null } + private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String { + if (snapshotPath.isBlank()) { + return "{}" + } + + val snapshotFile = File(snapshotPath) + if (!snapshotFile.exists()) { + return "{}" + } + + val result = JSONObject() + snapshotFile.forEachLine { line -> + if (line.isBlank()) return@forEachLine + val separatorIndex = line.indexOf('\t') + if (separatorIndex <= 0 || separatorIndex >= line.length - 1) { + return@forEachLine + } + val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L + val filePath = line.substring(separatorIndex + 1) + if (filePath.isNotEmpty()) { + result.put(filePath, modTime) + } + } + return result.toString() + } + private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { val obj = JSONObject() if (treeUriStr.isBlank() || fileName.isBlank()) { @@ -3186,6 +3212,18 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "scanLibraryFolderIncrementalFromSnapshot" -> { + val folderPath = call.argument("folder_path") ?: "" + val snapshotPath = call.argument("snapshot_path") ?: "" + val response = withContext(Dispatchers.IO) { + safScanActive = false + Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON( + folderPath, + snapshotPath, + ) + } + result.success(response) + } "scanSafTree" -> { val treeUri = call.argument("tree_uri") ?: "" val response = withContext(Dispatchers.IO) { @@ -3201,6 +3239,16 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "scanSafTreeIncrementalFromSnapshot" -> { + val treeUri = call.argument("tree_uri") ?: "" + val snapshotPath = call.argument("snapshot_path") ?: "" + val response = withContext(Dispatchers.IO) { + val existingFilesJson = + loadExistingFilesJsonFromSnapshot(snapshotPath) + scanSafTreeIncremental(treeUri, existingFilesJson) + } + result.success(response) + } "getSafFileModTimes" -> { val uris = call.argument("uris") ?: "[]" val response = withContext(Dispatchers.IO) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 674cc01d..2e833c01 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -3194,6 +3194,10 @@ func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (str return ScanLibraryFolderIncremental(folderPath, existingFilesJSON) } +func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) { + return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath) +} + func GetLibraryScanProgressJSON() string { return GetLibraryScanProgress() } diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index cb831364..20cfd626 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -1,10 +1,12 @@ package gobackend import ( + "bufio" "encoding/json" "fmt" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -541,7 +543,43 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, // ScanLibraryFolderIncremental performs an incremental scan of the library folder // existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis) // Only files that are new or have changed modification time will be scanned -func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) { +func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) { + existingFiles := make(map[string]int64) + if snapshotPath == "" { + return existingFiles, nil + } + + file, err := os.Open(snapshotPath) + if err != nil { + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + continue + } + modTime, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + continue + } + existingFiles[parts[1]] = modTime + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return existingFiles, nil +} + +func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) { if folderPath == "" { return "{}", fmt.Errorf("folder path is empty") } @@ -554,13 +592,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, return "{}", fmt.Errorf("path is not a folder: %s", folderPath) } - existingFiles := make(map[string]int64) - if existingFilesJSON != "" && existingFilesJSON != "{}" { - if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil { - GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err) - } - } - GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles)) libraryScanProgressMu.Lock() @@ -764,3 +795,24 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, return string(jsonBytes), nil } + +// ScanLibraryFolderIncremental performs an incremental scan of the library folder +// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis) +// Only files that are new or have changed modification time will be scanned +func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) { + existingFiles := make(map[string]int64) + if existingFilesJSON != "" && existingFilesJSON != "{}" { + if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil { + GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err) + } + } + return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles) +} + +func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) { + existingFiles, err := loadExistingFilesSnapshot(snapshotPath) + if err != nil { + return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err) + } + return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles) +} diff --git a/go_backend/progress.go b/go_backend/progress.go index 95ba3fde..2117d242 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -34,10 +34,16 @@ var ( downloadDir string downloadDirMu sync.RWMutex - multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} - multiMu sync.RWMutex + multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} + multiMu sync.RWMutex + multiProgressDirty = true + cachedMultiProgress = "{\"items\":{}}" ) +func markMultiProgressDirtyLocked() { + multiProgressDirty = true +} + func getProgress() DownloadProgress { multiMu.RLock() defer multiMu.RUnlock() @@ -58,13 +64,25 @@ func getProgress() DownloadProgress { func GetMultiProgress() string { multiMu.RLock() - defer multiMu.RUnlock() + if !multiProgressDirty { + cached := cachedMultiProgress + multiMu.RUnlock() + return cached + } + multiMu.RUnlock() + multiMu.Lock() + defer multiMu.Unlock() + if !multiProgressDirty { + return cachedMultiProgress + } jsonBytes, err := json.Marshal(multiProgress) if err != nil { return "{\"items\":{}}" } - return string(jsonBytes) + cachedMultiProgress = string(jsonBytes) + multiProgressDirty = false + return cachedMultiProgress } func GetItemProgress(itemID string) string { @@ -90,6 +108,7 @@ func StartItemProgress(itemID string) { IsDownloading: true, Status: "downloading", } + markMultiProgressDirtyLocked() } func SetItemBytesTotal(itemID string, total int64) { @@ -98,6 +117,7 @@ func SetItemBytesTotal(itemID string, total int64) { if item, ok := multiProgress.Items[itemID]; ok { item.BytesTotal = total + markMultiProgressDirtyLocked() } } @@ -110,6 +130,7 @@ func SetItemBytesReceived(itemID string, received int64) { if item.BytesTotal > 0 { item.Progress = float64(received) / float64(item.BytesTotal) } + markMultiProgressDirtyLocked() } } @@ -123,6 +144,7 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa if item.BytesTotal > 0 { item.Progress = float64(received) / float64(item.BytesTotal) } + markMultiProgressDirtyLocked() } } @@ -134,6 +156,7 @@ func CompleteItemProgress(itemID string) { item.Progress = 1.0 item.IsDownloading = false item.Status = "completed" + markMultiProgressDirtyLocked() } } @@ -149,6 +172,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal if bytesTotal > 0 { item.BytesTotal = bytesTotal } + markMultiProgressDirtyLocked() } } @@ -159,6 +183,7 @@ func SetItemFinalizing(itemID string) { if item, ok := multiProgress.Items[itemID]; ok { item.Progress = 1.0 item.Status = "finalizing" + markMultiProgressDirtyLocked() } } @@ -167,6 +192,7 @@ func RemoveItemProgress(itemID string) { defer multiMu.Unlock() delete(multiProgress.Items, itemID) + markMultiProgressDirtyLocked() } func ClearAllItemProgress() { @@ -174,6 +200,7 @@ func ClearAllItemProgress() { defer multiMu.Unlock() multiProgress.Items = make(map[string]*ItemProgress) + markMultiProgressDirtyLocked() } func setDownloadDir(path string) error { diff --git a/lib/main.dart b/lib/main.dart index e0f155e5..63d74d3a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; @@ -91,16 +92,18 @@ class _EagerInitialization extends ConsumerStatefulWidget { class _EagerInitializationState extends ConsumerState<_EagerInitialization> { ProviderSubscription? _localLibraryEnabledSub; - bool _localLibraryPreloaded = false; + Timer? _downloadHistoryWarmupTimer; + Timer? _libraryCollectionsWarmupTimer; + Timer? _localLibraryWarmupTimer; + bool _localLibraryWarmupScheduled = false; @override void initState() { super.initState(); - _initializeAppServices(); - _initializeExtensions(); - ref.read(downloadHistoryProvider); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + _initializeAppServices(); + _initializeExtensions(); _initializeDeferredProviders(); }); } @@ -108,12 +111,23 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { @override void dispose() { _localLibraryEnabledSub?.close(); + _downloadHistoryWarmupTimer?.cancel(); + _libraryCollectionsWarmupTimer?.cancel(); + _localLibraryWarmupTimer?.cancel(); super.dispose(); } void _initializeDeferredProviders() { - ref.read(libraryCollectionsProvider); - _maybePreloadLocalLibrary( + _downloadHistoryWarmupTimer = _scheduleProviderWarmup( + const Duration(milliseconds: 400), + () => ref.read(downloadHistoryProvider), + ); + _libraryCollectionsWarmupTimer = _scheduleProviderWarmup( + const Duration(milliseconds: 900), + () => ref.read(libraryCollectionsProvider), + ); + + _maybeScheduleLocalLibraryWarmup( ref.read( settingsProvider.select((settings) => settings.localLibraryEnabled), ), @@ -123,18 +137,26 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { settingsProvider.select((settings) => settings.localLibraryEnabled), (previous, next) { if (next == true) { - _maybePreloadLocalLibrary(true); + _maybeScheduleLocalLibraryWarmup(true); } }, ); } - void _maybePreloadLocalLibrary(bool enabled) { - if (!enabled || _localLibraryPreloaded) return; - _localLibraryPreloaded = true; - ref.read(localLibraryProvider); - _localLibraryEnabledSub?.close(); - _localLibraryEnabledSub = null; + Timer _scheduleProviderWarmup(Duration delay, VoidCallback action) { + return Timer(delay, () { + if (!mounted) return; + action(); + }); + } + + void _maybeScheduleLocalLibraryWarmup(bool enabled) { + if (!enabled || _localLibraryWarmupScheduled) return; + _localLibraryWarmupScheduled = true; + _localLibraryWarmupTimer = _scheduleProviderWarmup( + const Duration(milliseconds: 1600), + () => ref.read(localLibraryProvider), + ); } Future _initializeAppServices() async { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 9df46a5b..4761a22a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -929,11 +929,12 @@ class DownloadQueueNotifier extends Notifier { StreamSubscription>? _progressStreamSub; int _downloadCount = 0; static const _cleanupInterval = 50; - static const _progressPollingInterval = Duration(milliseconds: 800); + static const _progressPollingInterval = Duration(milliseconds: 1200); static const _idleProgressPollEveryTicks = 3; static const _queueSchedulingInterval = Duration(milliseconds: 250); static const _queuePersistDebounceDuration = Duration(milliseconds: 350); static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. + static const _serviceProgressStepPercent = 2; final NotificationService _notificationService = NotificationService(); final AppStateDatabase _appStateDb = AppStateDatabase.instance; int _totalQueuedAtStart = 0; @@ -1470,12 +1471,17 @@ class DownloadQueueNotifier extends Notifier { .round() .clamp(0, 100) .toInt(); + final progressBucket = progressPercent == 100 + ? 100 + : ((progressPercent ~/ _serviceProgressStepPercent) * + _serviceProgressStepPercent) + .clamp(0, 100); final didContentChange = trackName != _lastServiceTrackName || artistName != _lastServiceArtistName || queueCount != _lastServiceQueueCount || - progressPercent != _lastServicePercent; + progressBucket != _lastServicePercent; final allowHeartbeat = now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5); @@ -1485,7 +1491,7 @@ class DownloadQueueNotifier extends Notifier { _lastServiceTrackName = trackName; _lastServiceArtistName = artistName; - _lastServicePercent = progressPercent; + _lastServicePercent = progressBucket; _lastServiceQueueCount = queueCount; _lastServiceUpdateAt = now; diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 33f1ae6a..78ac4f6f 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -120,7 +120,7 @@ class LocalLibraryNotifier extends Notifier { final LibraryDatabase _db = LibraryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance; final NotificationService _notificationService = NotificationService(); - static const _progressPollingInterval = Duration(milliseconds: 800); + static const _progressPollingInterval = Duration(milliseconds: 1200); Timer? _progressTimer; Timer? _progressStreamBootstrapTimer; StreamSubscription>? _progressStreamSub; @@ -378,18 +378,41 @@ class LocalLibraryNotifier extends Notifier { _log.i('Backfilled ${backfilledModTimes.length} legacy mod times'); } - // Use appropriate incremental scan method based on SAF or not - final Map result; - if (isSaf) { - result = await PlatformBridge.scanSafTreeIncremental( - effectiveFolderPath, - existingFiles, - ); - } else { - result = await PlatformBridge.scanLibraryFolderIncremental( - effectiveFolderPath, - existingFiles, - ); + final useSnapshotBridge = + Platform.isAndroid && existingFiles.isNotEmpty; + final snapshotPath = useSnapshotBridge + ? await _db.writeFileModTimesSnapshot() + : null; + + Map result; + try { + if (isSaf) { + result = useSnapshotBridge && snapshotPath != null + ? await PlatformBridge.scanSafTreeIncrementalFromSnapshot( + effectiveFolderPath, + snapshotPath, + ) + : await PlatformBridge.scanSafTreeIncremental( + effectiveFolderPath, + existingFiles, + ); + } else { + result = useSnapshotBridge && snapshotPath != null + ? await PlatformBridge.scanLibraryFolderIncrementalFromSnapshot( + effectiveFolderPath, + snapshotPath, + ) + : await PlatformBridge.scanLibraryFolderIncremental( + effectiveFolderPath, + existingFiles, + ); + } + } finally { + if (snapshotPath != null) { + try { + await File(snapshotPath).delete(); + } catch (_) {} + } } if (_scanCancelRequested) { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d7c2a8eb..142c59de 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -81,6 +82,37 @@ class _SearchResultBuckets { }); } +const _homeHistoryPreviewLimit = 48; + +class _HomeHistoryPreview { + final List items; + + const _HomeHistoryPreview(this.items); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _HomeHistoryPreview && listEquals(items, other.items); + + @override + int get hashCode => Object.hashAll(items); +} + +final _homeHistoryPreviewProvider = Provider>((ref) { + final preview = ref.watch( + downloadHistoryProvider.select((s) { + final items = s.items; + if (items.length <= _homeHistoryPreviewLimit) { + return _HomeHistoryPreview(items); + } + return _HomeHistoryPreview( + items.take(_homeHistoryPreviewLimit).toList(growable: false), + ); + }), + ); + return preview.items; +}); + _RecentAccessView _buildRecentAccessViewData( List items, List historyItems, @@ -164,9 +196,7 @@ _RecentAccessView _buildRecentAccessViewData( } final recentAccessViewProvider = Provider<_RecentAccessView>((ref) { - final historyItems = ref.watch( - downloadHistoryProvider.select((s) => s.items), - ); + final historyItems = ref.watch(_homeHistoryPreviewProvider); final recentAccessItems = ref.watch( recentAccessProvider.select((s) => s.items), ); @@ -987,9 +1017,7 @@ class _HomeTabState extends ConsumerState final mediaQuery = MediaQuery.of(context); final screenHeight = mediaQuery.size.height; final topPadding = normalizedHeaderTopPadding(context); - final historyItems = ref.watch( - downloadHistoryProvider.select((s) => s.items), - ); + final historyItems = ref.watch(_homeHistoryPreviewProvider); final recentModeRequested = isShowingRecentAccess || isSearchFocused; final showRecentAccess = @@ -1822,9 +1850,9 @@ class _HomeTabState extends ConsumerState ), ); } else { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.homeAlbumInfoUnavailable))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.homeAlbumInfoUnavailable)), + ); } } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index cf9ceab5..70c21461 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -33,7 +33,7 @@ class MainShell extends ConsumerStatefulWidget { class _MainShellState extends ConsumerState { int _currentIndex = 0; - late PageController _pageController; + late final PageController _pageController; bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; @@ -113,17 +113,18 @@ class _MainShellState extends ConsumerState { if (trackState.error != null && mounted) { final l10n = context.l10n; final errorMsg = trackState.error!; - final isRateLimit = errorMsg.contains('429') || + final isRateLimit = + errorMsg.contains('429') || errorMsg.toLowerCase().contains('rate limit') || errorMsg.toLowerCase().contains('too many requests'); final displayMessage = errorMsg == 'url_not_recognized' ? l10n.errorUrlNotRecognizedMessage : isRateLimit - ? l10n.errorRateLimitedMessage - : l10n.errorUrlFetchFailed; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(displayMessage)), - ); + ? l10n.errorRateLimitedMessage + : l10n.errorUrlFetchFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(displayMessage))); } } @@ -212,9 +213,7 @@ class _MainShellState extends ConsumerState { ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.safMigrationSuccess), - ), + SnackBar(content: Text(context.l10n.safMigrationSuccess)), ); } } @@ -253,6 +252,7 @@ class _MainShellState extends ConsumerState { } if (_currentIndex != index) { + final shouldResetHome = index == 0; HapticFeedback.selectionClick(); setState(() => _currentIndex = index); final showStore = ref.read( @@ -262,6 +262,10 @@ class _MainShellState extends ConsumerState { currentTabIndex: _currentIndex, showStoreTab: showStore, ); + FocusManager.instance.primaryFocus?.unfocus(); + if (shouldResetHome) { + _resetHomeToMain(); + } _pageController.animateToPage( index, duration: const Duration(milliseconds: 250), @@ -501,11 +505,15 @@ class _MainShellState extends ConsumerState { return true; }, child: Scaffold( - body: PageView( + body: PageView.builder( controller: _pageController, + itemCount: tabs.length, onPageChanged: _onPageChanged, physics: const NeverScrollableScrollPhysics(), - children: tabs, + itemBuilder: (context, index) => _KeepAliveTabPage( + key: ValueKey('page-$index'), + child: tabs[index], + ), ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), @@ -566,6 +574,27 @@ class _LibraryTabRoot extends ConsumerWidget { } } +class _KeepAliveTabPage extends StatefulWidget { + final Widget child; + + const _KeepAliveTabPage({super.key, required this.child}); + + @override + State<_KeepAliveTabPage> createState() => _KeepAliveTabPageState(); +} + +class _KeepAliveTabPageState extends State<_KeepAliveTabPage> + with AutomaticKeepAliveClientMixin<_KeepAliveTabPage> { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} + class BouncingIcon extends StatefulWidget { final Widget child; const BouncingIcon({super.key, required this.child}); diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 8645950d..0a56f7a1 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -314,6 +314,348 @@ class _QueueItemIdsSnapshot { int get hashCode => Object.hashAll(ids); } +class _QueueGroupedAlbumFilterRequest { + final String searchQuery; + final String? filterSource; + final String? filterQuality; + final String? filterFormat; + final String sortMode; + + const _QueueGroupedAlbumFilterRequest({ + required this.searchQuery, + required this.filterSource, + required this.filterQuality, + required this.filterFormat, + required this.sortMode, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _QueueGroupedAlbumFilterRequest && + searchQuery == other.searchQuery && + filterSource == other.filterSource && + filterQuality == other.filterQuality && + filterFormat == other.filterFormat && + sortMode == other.sortMode; + + @override + int get hashCode => Object.hash( + searchQuery, + filterSource, + filterQuality, + filterFormat, + sortMode, + ); +} + +String _queueFileExtLower(String filePath) { + final slashIndex = filePath.lastIndexOf('/'); + final dotIndex = filePath.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex < slashIndex + 1) { + return ''; + } + return filePath.substring(dotIndex + 1).toLowerCase(); +} + +String? _queueLocalQualityLabel(LocalLibraryItem item) { + if (item.bitrate != null && item.bitrate! > 0) { + return '${item.bitrate}kbps'; + } + if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { + return null; + } + return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; +} + +bool _queuePassesQualityFilter(String? filterQuality, String? quality) { + if (filterQuality == null) return true; + if (quality == null) return filterQuality == 'lossy'; + final normalized = quality.toLowerCase(); + switch (filterQuality) { + case 'hires': + return normalized.startsWith('24'); + case 'cd': + return normalized.startsWith('16'); + case 'lossy': + return !normalized.startsWith('24') && !normalized.startsWith('16'); + default: + return true; + } +} + +bool _queuePassesFormatFilter(String? filterFormat, String filePath) { + if (filterFormat == null) return true; + return _queueFileExtLower(filePath) == filterFormat; +} + +_HistoryStats _buildQueueHistoryStats( + List items, [ + List localItems = const [], +]) { + final albumCounts = {}; + final albumMap = >{}; + for (final item in items) { + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + albumCounts[key] = (albumCounts[key] ?? 0) + 1; + albumMap.putIfAbsent(key, () => []).add(item); + } + + var singleTracks = 0; + for (final item in items) { + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + if ((albumCounts[key] ?? 0) <= 1) { + singleTracks++; + } + } + + final groupedAlbums = <_GroupedAlbum>[]; + albumMap.forEach((_, tracks) { + if (tracks.length <= 1) return; + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + groupedAlbums.add( + _GroupedAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverUrl: tracks.first.coverUrl, + sampleFilePath: tracks.first.filePath, + tracks: tracks, + latestDownload: tracks + .map((t) => t.downloadedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ), + ); + }); + groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); + + var albumCount = 0; + for (final count in albumCounts.values) { + if (count > 1) albumCount++; + } + + final downloadedPathKeys = {}; + for (final item in items) { + downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); + } + + final dedupedLocalItems = localItems + .where((item) { + final localPathKeys = buildPathMatchKeys(item.filePath); + return !localPathKeys.any(downloadedPathKeys.contains); + }) + .toList(growable: false); + + final localAlbumCounts = {}; + final localAlbumMap = >{}; + for (final item in dedupedLocalItems) { + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; + localAlbumMap.putIfAbsent(key, () => []).add(item); + } + + var localAlbumCount = 0; + var localSingleTracks = 0; + for (final count in localAlbumCounts.values) { + if (count > 1) { + localAlbumCount++; + } else { + localSingleTracks++; + } + } + + final groupedLocalAlbums = <_GroupedLocalAlbum>[]; + localAlbumMap.forEach((_, tracks) { + if (tracks.length <= 1) return; + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + groupedLocalAlbums.add( + _GroupedLocalAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverPath: tracks + .firstWhere( + (t) => t.coverPath != null && t.coverPath!.isNotEmpty, + orElse: () => tracks.first, + ) + .coverPath, + tracks: tracks, + latestScanned: tracks + .map((t) => t.scannedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ), + ); + }); + groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned)); + + return _HistoryStats( + albumCounts: albumCounts, + localAlbumCounts: localAlbumCounts, + groupedAlbums: groupedAlbums, + groupedLocalAlbums: groupedLocalAlbums, + albumCount: albumCount, + singleTracks: singleTracks, + localAlbumCount: localAlbumCount, + localSingleTracks: localSingleTracks, + ); +} + +List<_GroupedAlbum> _queueFilterGroupedAlbums( + List<_GroupedAlbum> albums, + _QueueGroupedAlbumFilterRequest request, +) { + if (request.filterSource == 'local') return const []; + if (request.filterSource == null && + request.filterQuality == null && + request.filterFormat == null && + request.searchQuery.isEmpty && + request.sortMode == 'latest') { + return albums; + } + + final result = <_GroupedAlbum>[]; + for (final album in albums) { + if (request.searchQuery.isNotEmpty && + !album.searchKey.contains(request.searchQuery)) { + continue; + } + + if (request.filterQuality != null || request.filterFormat != null) { + var hasMatchingTrack = false; + for (final track in album.tracks) { + if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) { + continue; + } + if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { + continue; + } + hasMatchingTrack = true; + break; + } + if (!hasMatchingTrack) continue; + } + + result.add(album); + } + + switch (request.sortMode) { + case 'oldest': + result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload)); + case 'a-z': + result.sort( + (a, b) => + a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), + ); + case 'z-a': + result.sort( + (a, b) => + b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), + ); + default: + break; + } + return result; +} + +List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( + List<_GroupedLocalAlbum> albums, + _QueueGroupedAlbumFilterRequest request, +) { + if (request.filterSource == 'downloaded') return const []; + if (request.filterSource == null && + request.filterQuality == null && + request.filterFormat == null && + request.searchQuery.isEmpty && + request.sortMode == 'latest') { + return albums; + } + + final result = <_GroupedLocalAlbum>[]; + for (final album in albums) { + if (request.searchQuery.isNotEmpty && + !album.searchKey.contains(request.searchQuery)) { + continue; + } + + if (request.filterQuality != null || request.filterFormat != null) { + var hasMatchingTrack = false; + for (final track in album.tracks) { + if (!_queuePassesQualityFilter( + request.filterQuality, + _queueLocalQualityLabel(track), + )) { + continue; + } + if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { + continue; + } + hasMatchingTrack = true; + break; + } + if (!hasMatchingTrack) continue; + } + + result.add(album); + } + + switch (request.sortMode) { + case 'oldest': + result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned)); + case 'a-z': + result.sort( + (a, b) => + a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), + ); + case 'z-a': + result.sort( + (a, b) => + b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), + ); + default: + break; + } + return result; +} + +final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) { + final historyItems = ref.watch( + downloadHistoryProvider.select((s) => s.items), + ); + final localLibraryEnabled = ref.watch( + settingsProvider.select((s) => s.localLibraryEnabled), + ); + final localItems = localLibraryEnabled + ? ref.watch(localLibraryProvider.select((s) => s.items)) + : const []; + return _buildQueueHistoryStats(historyItems, localItems); +}); + +final _queueFilteredAlbumsProvider = + Provider.family< + ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}), + _QueueGroupedAlbumFilterRequest + >((ref, request) { + final historyStats = ref.watch(_queueHistoryStatsProvider); + return ( + albums: _queueFilterGroupedAlbums(historyStats.groupedAlbums, request), + localAlbums: _queueFilterGroupedLocalAlbums( + historyStats.groupedLocalAlbums, + request, + ), + ); + }); + Map> _filterHistoryInIsolate(Map payload) { final entries = (payload['entries'] as List).cast(); final albumCounts = (payload['albumCounts'] as Map).cast(); @@ -431,14 +773,6 @@ class _QueueTabState extends ConsumerState { String? _filterCacheQuality; String? _filterCacheFormat; String _filterCacheSortMode = 'latest'; - _HistoryStats? _groupedAlbumFilterHistoryStatsCache; - String _groupedAlbumFilterSearchQuery = ''; - String? _groupedAlbumFilterSource; - String? _groupedAlbumFilterQuality; - String? _groupedAlbumFilterFormat; - String _groupedAlbumFilterSortMode = 'latest'; - List<_GroupedAlbum> _filteredGroupedAlbumsCache = const []; - List<_GroupedLocalAlbum> _filteredGroupedLocalAlbumsCache = const []; // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' @@ -549,6 +883,7 @@ class _QueueTabState extends ConsumerState { void _ensureHistoryCaches( List items, List localItems, + _HistoryStats historyStats, ) { final historyChanged = !identical(items, _historyItemsCache); final localChanged = !identical(localItems, _localLibraryItemsCache); @@ -557,7 +892,7 @@ class _QueueTabState extends ConsumerState { _historyItemsCache = items; _localLibraryItemsCache = localItems; - _historyStatsCache = _buildHistoryStats(items, localItems); + _historyStatsCache = historyStats; if (historyChanged) { _searchIndexCache.clear(); } @@ -1361,18 +1696,6 @@ class _QueueTabState extends ConsumerState { return filePath.substring(dotIndex + 1).toLowerCase(); } - String? _localQualityLabel(LocalLibraryItem item) { - if (item.bitrate != null && item.bitrate! > 0) { - return '${item.bitrate}kbps'; - } - if (item.bitDepth == null || - item.bitDepth == 0 || - item.sampleRate == null) { - return null; - } - return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; - } - List _applyAdvancedFilters( List items, ) { @@ -1445,179 +1768,6 @@ class _QueueTabState extends ConsumerState { return sorted; } - bool _passesQualityFilter(String? quality) { - if (_filterQuality == null) return true; - if (quality == null) return _filterQuality == 'lossy'; - final q = quality.toLowerCase(); - switch (_filterQuality) { - case 'hires': - return q.startsWith('24'); - case 'cd': - return q.startsWith('16'); - case 'lossy': - return !q.startsWith('24') && !q.startsWith('16'); - default: - return true; - } - } - - bool _passesFormatFilter(String filePath) { - if (_filterFormat == null) return true; - return _fileExtLower(filePath) == _filterFormat; - } - - List<_GroupedAlbum> _filterGroupedAlbums( - List<_GroupedAlbum> albums, - String searchQuery, - ) { - if (_activeFilterCount == 0 && - searchQuery.isEmpty && - _sortMode == 'latest') { - return albums; - } - - // Source filter: if filtering local only, hide all download albums - if (_filterSource == 'local') return const []; - - final result = <_GroupedAlbum>[]; - for (final album in albums) { - if (searchQuery.isNotEmpty && !album.searchKey.contains(searchQuery)) { - continue; - } - - // Filter tracks within the album by advanced filters - if (_filterQuality != null || _filterFormat != null) { - var hasMatchingTrack = false; - for (final track in album.tracks) { - if (!_passesQualityFilter(track.quality)) continue; - if (!_passesFormatFilter(track.filePath)) continue; - hasMatchingTrack = true; - break; - } - - if (!hasMatchingTrack) continue; - } - - result.add(album); - } - - // Apply sorting to albums - switch (_sortMode) { - case 'oldest': - result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload)); - case 'a-z': - result.sort( - (a, b) => - a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), - ); - case 'z-a': - result.sort( - (a, b) => - b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), - ); - default: // 'latest' - already sorted - break; - } - - return result; - } - - List<_GroupedLocalAlbum> _filterGroupedLocalAlbums( - List<_GroupedLocalAlbum> albums, - String searchQuery, - ) { - if (_activeFilterCount == 0 && - searchQuery.isEmpty && - _sortMode == 'latest') { - return albums; - } - - // Source filter: if filtering downloaded only, hide all local albums - if (_filterSource == 'downloaded') return const []; - - final result = <_GroupedLocalAlbum>[]; - for (final album in albums) { - if (searchQuery.isNotEmpty && !album.searchKey.contains(searchQuery)) { - continue; - } - - // Filter tracks within the album by advanced filters - if (_filterQuality != null || _filterFormat != null) { - var hasMatchingTrack = false; - for (final track in album.tracks) { - if (!_passesQualityFilter(_localQualityLabel(track))) continue; - if (!_passesFormatFilter(track.filePath)) continue; - hasMatchingTrack = true; - break; - } - - if (!hasMatchingTrack) continue; - } - - result.add(album); - } - - // Apply sorting to local albums - switch (_sortMode) { - case 'oldest': - result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned)); - case 'a-z': - result.sort( - (a, b) => - a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), - ); - case 'z-a': - result.sort( - (a, b) => - b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), - ); - default: // 'latest' - already sorted - break; - } - - return result; - } - - ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}) - _resolveFilteredGroupedAlbums(_HistoryStats historyStats) { - final cacheValid = - identical(_groupedAlbumFilterHistoryStatsCache, historyStats) && - _groupedAlbumFilterSearchQuery == _searchQuery && - _groupedAlbumFilterSource == _filterSource && - _groupedAlbumFilterQuality == _filterQuality && - _groupedAlbumFilterFormat == _filterFormat && - _groupedAlbumFilterSortMode == _sortMode; - - if (cacheValid) { - return ( - albums: _filteredGroupedAlbumsCache, - localAlbums: _filteredGroupedLocalAlbumsCache, - ); - } - - final filteredGroupedAlbums = _filterGroupedAlbums( - historyStats.groupedAlbums, - _searchQuery, - ); - final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( - historyStats.groupedLocalAlbums, - _searchQuery, - ); - - _groupedAlbumFilterHistoryStatsCache = historyStats; - _groupedAlbumFilterSearchQuery = _searchQuery; - _groupedAlbumFilterSource = _filterSource; - _groupedAlbumFilterQuality = _filterQuality; - _groupedAlbumFilterFormat = _filterFormat; - _groupedAlbumFilterSortMode = _sortMode; - _filteredGroupedAlbumsCache = filteredGroupedAlbums; - _filteredGroupedLocalAlbumsCache = filteredGroupedLocalAlbums; - return ( - albums: filteredGroupedAlbums, - localAlbums: filteredGroupedLocalAlbums, - ); - } - Set _getAvailableFormats(List items) { final formats = {}; for (final item in items) { @@ -2060,134 +2210,6 @@ class _QueueTabState extends ConsumerState { } } - _HistoryStats _buildHistoryStats( - List items, [ - List localItems = const [], - ]) { - final albumCounts = {}; - final albumMap = >{}; - for (final item in items) { - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - albumCounts[key] = (albumCounts[key] ?? 0) + 1; - albumMap.putIfAbsent(key, () => []).add(item); - } - - int singleTracks = 0; - for (final item in items) { - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - if ((albumCounts[key] ?? 0) <= 1) { - singleTracks++; - } - } - - final groupedAlbums = <_GroupedAlbum>[]; - albumMap.forEach((_, tracks) { - if (tracks.length <= 1) return; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - groupedAlbums.add( - _GroupedAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverUrl: tracks.first.coverUrl, - sampleFilePath: tracks.first.filePath, - tracks: tracks, - latestDownload: tracks - .map((t) => t.downloadedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ), - ); - }); - - groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); - - int albumCount = 0; - for (final count in albumCounts.values) { - if (count > 1) albumCount++; - } - - final downloadedPathKeys = {}; - for (final item in items) { - downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); - } - - final dedupedLocalItems = localItems - .where((item) { - final localPathKeys = buildPathMatchKeys(item.filePath); - return !localPathKeys.any(downloadedPathKeys.contains); - }) - .toList(growable: false); - - // Calculate local library stats - final localAlbumCounts = {}; - final localAlbumMap = >{}; - for (final item in dedupedLocalItems) { - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; - localAlbumMap.putIfAbsent(key, () => []).add(item); - } - - int localAlbumCount = 0; - int localSingleTracks = 0; - for (final count in localAlbumCounts.values) { - if (count > 1) { - localAlbumCount++; - } else { - localSingleTracks++; - } - } - - // Build grouped local albums - final groupedLocalAlbums = <_GroupedLocalAlbum>[]; - localAlbumMap.forEach((_, tracks) { - if (tracks.length <= 1) return; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - groupedLocalAlbums.add( - _GroupedLocalAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverPath: tracks - .firstWhere( - (t) => t.coverPath != null && t.coverPath!.isNotEmpty, - orElse: () => tracks.first, - ) - .coverPath, - tracks: tracks, - latestScanned: tracks - .map((t) => t.scannedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ), - ); - }); - - groupedLocalAlbums.sort( - (a, b) => b.latestScanned.compareTo(a.latestScanned), - ); - - return _HistoryStats( - albumCounts: albumCounts, - localAlbumCounts: localAlbumCounts, - groupedAlbums: groupedAlbums, - groupedLocalAlbums: groupedLocalAlbums, - albumCount: albumCount, - singleTracks: singleTracks, - localAlbumCount: localAlbumCount, - localSingleTracks: localSingleTracks, - ); - } - void _navigateToDownloadedAlbum(_GroupedAlbum album) { _searchFocusNode.unfocus(); Navigator.push( @@ -2541,8 +2563,19 @@ class _QueueTabState extends ConsumerState { ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; final collectionState = ref.watch(libraryCollectionsProvider); - - _ensureHistoryCaches(allHistoryItems, localLibraryItems); + final historyStats = ref.watch(_queueHistoryStatsProvider); + final filteredGrouped = ref.watch( + _queueFilteredAlbumsProvider( + _QueueGroupedAlbumFilterRequest( + searchQuery: _searchQuery, + filterSource: _filterSource, + filterQuality: _filterQuality, + filterFormat: _filterFormat, + sortMode: _sortMode, + ), + ), + ); + _ensureHistoryCaches(allHistoryItems, localLibraryItems, historyStats); final historyViewMode = ref.watch( settingsProvider.select((s) => s.historyViewMode), ); @@ -2551,11 +2584,6 @@ class _QueueTabState extends ConsumerState { ); final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); - - final historyStats = - _historyStatsCache ?? - _buildHistoryStats(allHistoryItems, localLibraryItems); - final filteredGrouped = _resolveFilteredGroupedAlbums(historyStats); final filteredGroupedAlbums = filteredGrouped.albums; final filteredGroupedLocalAlbums = filteredGrouped.localAlbums; final albumCount = historyStats.totalAlbumCount; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index cf37c308..16955171 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; @@ -470,6 +472,34 @@ class LibraryDatabase { return result; } + /// Export file modification times to a compact line-based snapshot that + /// native code can read without receiving a large method-channel payload. + Future writeFileModTimesSnapshot() async { + final db = await database; + final rows = await db.rawQuery( + 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library', + ); + final tempDir = await getTemporaryDirectory(); + final file = File( + join( + tempDir.path, + 'library_file_mod_times_${DateTime.now().microsecondsSinceEpoch}.tsv', + ), + ); + final buffer = StringBuffer(); + for (final row in rows) { + final path = row['file_path'] as String?; + if (path == null || path.isEmpty) continue; + final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0; + buffer + ..write(modTime) + ..write('\t') + ..writeln(path); + } + await file.writeAsString(buffer.toString(), flush: true); + return file.path; + } + /// Update file_mod_time for existing rows using file_path as key. Future updateFileModTimes(Map fileModTimes) async { if (fileModTimes.isEmpty) return; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index d5e5c46f..99696825 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1090,6 +1090,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> scanLibraryFolderIncrementalFromSnapshot( + String folderPath, + String snapshotPath, + ) async { + final result = await _channel.invokeMethod( + 'scanLibraryFolderIncrementalFromSnapshot', + {'folder_path': folderPath, 'snapshot_path': snapshotPath}, + ); + return jsonDecode(result as String) as Map; + } + static Future>> scanSafTree(String treeUri) async { _log.i('scanSafTree: $treeUri'); final result = await _channel.invokeMethod('scanSafTree', { @@ -1115,6 +1126,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> scanSafTreeIncrementalFromSnapshot( + String treeUri, + String snapshotPath, + ) async { + final result = await _channel.invokeMethod( + 'scanSafTreeIncrementalFromSnapshot', + {'tree_uri': treeUri, 'snapshot_path': snapshotPath}, + ); + return jsonDecode(result as String) as Map; + } + /// Get last-modified timestamps for a list of SAF file URIs. /// Returns map uri -> modTime (unix millis), only for files that still exist. static Future> getSafFileModTimes(List uris) async {