From c1c0494912f14bb8574421a4eab934fd0adfef0f Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 26 Jun 2026 22:39:22 +0700 Subject: [PATCH] perf: optimize queue/library pagination, counts, scan, and extension runtime - Queue/Library: switch from growing-limit refetch to offset-based append pagination per filter/search/sort, with page cache reassembly and protected-page eviction to avoid top-of-list gaps - Queue/Library: watch only the active filter page instead of all/singles/albums simultaneously; filter mode follows PageView swipe and tab tap - library_database: combine three union-based count queries into a single round-trip - library_scan: parallel scan of non-CUE audio files with a bounded 2-4 worker pool, preserving order, progress, and cancellation; CUE handling stays sequential - extension_manager: cache compiled goja.Program per extension and RunProgram per isolated download instead of re-reading and re-parsing index.js --- go_backend/extension_manager.go | 48 +++-- go_backend/library_scan.go | 224 +++++++++++++++++--- lib/screens/queue_tab.dart | 327 +++++++++++++++++------------ lib/screens/queue_tab_helpers.dart | 32 +++ lib/services/library_database.dart | 28 ++- 5 files changed, 464 insertions(+), 195 deletions(-) diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index f8c678c5..a50bd039 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -50,17 +50,18 @@ func isExtensionPackagePath(filePath string) bool { } type loadedExtension struct { - ID string `json:"id"` - Manifest *ExtensionManifest `json:"manifest"` - VM *goja.Runtime `json:"-"` - VMMu sync.Mutex `json:"-"` - runtime *extensionRuntime - initialized bool - Enabled bool `json:"enabled"` - Error string `json:"error,omitempty"` - DataDir string `json:"data_dir"` - SourceDir string `json:"source_dir"` - IconPath string `json:"icon_path"` + ID string `json:"id"` + Manifest *ExtensionManifest `json:"manifest"` + VM *goja.Runtime `json:"-"` + VMMu sync.Mutex `json:"-"` + runtime *extensionRuntime + indexProgram *goja.Program + initialized bool + Enabled bool `json:"enabled"` + Error string `json:"error,omitempty"` + DataDir string `json:"data_dir"` + SourceDir string `json:"source_dir"` + IconPath string `json:"icon_path"` } func getExtensionInitSettings(extensionID string) map[string]interface{} { @@ -311,6 +312,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded func initializeVMLocked(ext *loadedExtension) error { ext.VM = nil ext.runtime = nil + ext.indexProgram = nil ext.initialized = false vm := goja.New() ext.VM = vm @@ -320,6 +322,11 @@ func initializeVMLocked(ext *loadedExtension) error { if err != nil { return fmt.Errorf("failed to read index.js: %w", err) } + indexProgram, err := goja.Compile(indexPath, string(jsCode), false) + if err != nil { + return fmt.Errorf("failed to compile extension code: %w", err) + } + ext.indexProgram = indexProgram runtime := newExtensionRuntime(ext) ext.runtime = runtime @@ -346,7 +353,7 @@ func initializeVMLocked(ext *loadedExtension) error { return goja.Undefined() }) - _, err = vm.RunString(string(jsCode)) + _, err = vm.RunProgram(indexProgram) if err != nil { return fmt.Errorf("failed to execute extension code: %w", err) } @@ -361,10 +368,17 @@ func initializeVMLocked(ext *loadedExtension) error { func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) { vm := goja.New() - indexPath := filepath.Join(ext.SourceDir, "index.js") - jsCode, err := os.ReadFile(indexPath) - if err != nil { - return nil, nil, fmt.Errorf("failed to read index.js: %w", err) + indexProgram := ext.indexProgram + if indexProgram == nil { + indexPath := filepath.Join(ext.SourceDir, "index.js") + jsCode, err := os.ReadFile(indexPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to read index.js: %w", err) + } + indexProgram, err = goja.Compile(indexPath, string(jsCode), false) + if err != nil { + return nil, nil, fmt.Errorf("failed to compile extension code: %w", err) + } } runtime := &extensionRuntime{ @@ -407,7 +421,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio return goja.Undefined() }) - if _, err := vm.RunString(string(jsCode)); err != nil { + if _, err := vm.RunProgram(indexProgram); err != nil { runtime.closeStorageFlusher() return nil, nil, fmt.Errorf("failed to execute extension code: %w", err) } diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 65328959..7e3d9d90 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -92,6 +93,18 @@ type scannedCueFileInfo struct { audioPath string } +type libraryScanTask struct { + index int + info libraryAudioFileInfo +} + +type libraryScanTaskResult struct { + index int + path string + results []LibraryScanResult + err error +} + func isLibraryStagingFile(path string) bool { name := strings.ToLower(filepath.Base(path)) if strings.HasSuffix(name, ".partial") { @@ -150,6 +163,129 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li return files, nil } +func libraryScanWorkerCount(taskCount int) int { + if taskCount < 16 { + return 1 + } + workers := runtime.NumCPU() + if workers > 4 { + workers = 4 + } + if workers < 2 { + workers = 2 + } + if workers > taskCount { + workers = taskCount + } + return workers +} + +func updateLibraryScanProgress(scannedFiles, totalFiles int, currentPath string) { + libraryScanProgressMu.Lock() + libraryScanProgress.ScannedFiles = scannedFiles + libraryScanProgress.CurrentFile = filepath.Base(currentPath) + if totalFiles > 0 { + libraryScanProgress.ProgressPct = float64(scannedFiles) / float64(totalFiles) * 100 + } + libraryScanProgressMu.Unlock() +} + +func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, cancelCh <-chan struct{}, totalFiles int, completed *int) (map[int][]LibraryScanResult, int, error) { + resultsByIndex := make(map[int][]LibraryScanResult, len(tasks)) + if len(tasks) == 0 { + return resultsByIndex, 0, nil + } + + workers := libraryScanWorkerCount(len(tasks)) + if workers <= 1 { + errorCount := 0 + for _, task := range tasks { + select { + case <-cancelCh: + return resultsByIndex, errorCount, fmt.Errorf("scan cancelled") + default: + } + result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime) + *completed++ + updateLibraryScanProgress(*completed, totalFiles, task.info.path) + if err != nil { + errorCount++ + GoLog("[LibraryScan] Error scanning %s: %v\n", task.info.path, err) + continue + } + resultsByIndex[task.index] = []LibraryScanResult{*result} + } + return resultsByIndex, errorCount, nil + } + + taskCh := make(chan libraryScanTask) + resultCh := make(chan libraryScanTaskResult, workers) + var wg sync.WaitGroup + + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for task := range taskCh { + select { + case <-cancelCh: + return + default: + } + result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime) + taskResult := libraryScanTaskResult{ + index: task.index, + path: task.info.path, + err: err, + } + if err == nil && result != nil { + taskResult.results = []LibraryScanResult{*result} + } + select { + case <-cancelCh: + return + case resultCh <- taskResult: + } + } + }() + } + + go func() { + defer close(taskCh) + for _, task := range tasks { + select { + case <-cancelCh: + return + case taskCh <- task: + } + } + }() + + go func() { + wg.Wait() + close(resultCh) + }() + + errorCount := 0 + for taskResult := range resultCh { + *completed++ + updateLibraryScanProgress(*completed, totalFiles, taskResult.path) + if taskResult.err != nil { + errorCount++ + GoLog("[LibraryScan] Error scanning %s: %v\n", taskResult.path, taskResult.err) + continue + } + resultsByIndex[taskResult.index] = taskResult.results + } + + select { + case <-cancelCh: + return resultsByIndex, errorCount, fmt.Errorf("scan cancelled") + default: + } + return resultsByIndex, errorCount, nil +} + func SetLibraryCoverCacheDir(cacheDir string) { libraryCoverCacheMu.Lock() libraryCoverCacheDir = cacheDir @@ -225,6 +361,10 @@ func ScanLibraryFolder(folderPath string) (string, error) { } } + resultsByIndex := make(map[int][]LibraryScanResult, totalFiles) + audioTasks := make([]libraryScanTask, 0, totalFiles) + completedFiles := 0 + for i, fileInfo := range audioFileInfos { filePath := fileInfo.path select { @@ -233,12 +373,6 @@ func ScanLibraryFolder(folderPath string) (string, error) { default: } - libraryScanProgressMu.Lock() - libraryScanProgress.ScannedFiles = i + 1 - libraryScanProgress.CurrentFile = filepath.Base(filePath) - libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100 - libraryScanProgressMu.Unlock() - ext := strings.ToLower(filepath.Ext(filePath)) if ext == ".cue" { @@ -260,26 +394,44 @@ func ScanLibraryFolder(folderPath string) (string, error) { if err != nil { errorCount++ GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err) + completedFiles++ + updateLibraryScanProgress(completedFiles, totalFiles, filePath) continue } - results = append(results, cueResults...) + resultsByIndex[i] = cueResults + completedFiles++ + updateLibraryScanProgress(completedFiles, totalFiles, filePath) GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults)) continue } if cueReferencedAudioFiles[filePath] { + completedFiles++ + updateLibraryScanProgress(completedFiles, totalFiles, filePath) GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath)) continue } - result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime) - if err != nil { - errorCount++ - GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err) - continue - } + audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo}) + } - results = append(results, *result) + audioResults, audioErrors, err := scanLibraryAudioTasksParallel( + audioTasks, + scanTime, + cancelCh, + totalFiles, + &completedFiles, + ) + if err != nil { + return "[]", err + } + errorCount += audioErrors + for index, scanResults := range audioResults { + resultsByIndex[index] = scanResults + } + + for i := range audioFileInfos { + results = append(results, resultsByIndex[i]...) } libraryScanProgressMu.Lock() @@ -874,6 +1026,10 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi } } + resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan)) + audioTasks := make([]libraryScanTask, 0, len(filesToScan)) + completedFiles := skippedCount + for i, f := range filesToScan { select { case <-cancelCh: @@ -881,12 +1037,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi default: } - libraryScanProgressMu.Lock() - libraryScanProgress.ScannedFiles = skippedCount + i + 1 - libraryScanProgress.CurrentFile = filepath.Base(f.path) - libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100 - libraryScanProgressMu.Unlock() - ext := strings.ToLower(filepath.Ext(f.path)) if ext == ".cue" { @@ -908,24 +1058,42 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi if err != nil { errorCount++ GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err) + completedFiles++ + updateLibraryScanProgress(completedFiles, totalFiles, f.path) continue } - results = append(results, cueResults...) + resultsByIndex[i] = cueResults + completedFiles++ + updateLibraryScanProgress(completedFiles, totalFiles, f.path) continue } if cueReferencedAudioFilesInc[f.path] { + completedFiles++ + updateLibraryScanProgress(completedFiles, totalFiles, f.path) continue } - result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime) - if err != nil { - errorCount++ - GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err) - continue - } + audioTasks = append(audioTasks, libraryScanTask{index: i, info: f}) + } - results = append(results, *result) + audioResults, audioErrors, err := scanLibraryAudioTasksParallel( + audioTasks, + scanTime, + cancelCh, + totalFiles, + &completedFiles, + ) + if err != nil { + return "{}", err + } + errorCount += audioErrors + for index, scanResults := range audioResults { + resultsByIndex[index] = scanResults + } + + for i := range filesToScan { + results = append(results, resultsByIndex[i]...) } libraryScanProgressMu.Lock() diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index ea852fbe..0169cf38 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -227,7 +227,7 @@ class _QueueTabState extends ConsumerState { String _sortMode = 'latest'; double _libraryGridExtent = _libraryGridDefaultExtent; double? _libraryGridScaleStartExtent; - int _libraryPageLimit = _libraryPageSize; + final Map _libraryPageOffsetByFilter = {}; bool _libraryPageLoadScheduled = false; final Map<_QueueLibraryCountsRequest, QueueLibraryCounts> _queueLibraryCountsCache = {}; @@ -305,7 +305,7 @@ class _QueueTabState extends ConsumerState { if (!mounted || _searchQuery == normalized) return; setState(() { _searchQuery = normalized; - _libraryPageLimit = _libraryPageSize; + _resetLibraryPaging(); }); _requestFilterRefresh(); }); @@ -316,16 +316,30 @@ class _QueueTabState extends ConsumerState { if (_searchQuery.isEmpty) return; setState(() { _searchQuery = ''; - _libraryPageLimit = _libraryPageSize; + _resetLibraryPaging(); }); _requestFilterRefresh(); } - void _loadMoreLibraryItems({required bool hasMoreLibrary}) { + int _libraryPageOffsetFor(String filterMode) => + _libraryPageOffsetByFilter[filterMode] ?? 0; + + void _resetLibraryPaging() { + _libraryPageOffsetByFilter.clear(); + _queueLibraryPageDataCache.clear(); + } + + void _loadMoreLibraryItems({ + required String filterMode, + required bool hasMoreLibrary, + }) { if (_libraryPageLoadScheduled) return; _libraryPageLoadScheduled = true; setState(() { - if (hasMoreLibrary) _libraryPageLimit += _libraryPageSize; + if (hasMoreLibrary) { + _libraryPageOffsetByFilter[filterMode] = + _libraryPageOffsetFor(filterMode) + _libraryPageSize; + } }); WidgetsBinding.instance.addPostFrameCallback((_) { _libraryPageLoadScheduled = false; @@ -339,7 +353,7 @@ class _QueueTabState extends ConsumerState { return value.maybeWhen( data: (counts) { _queueLibraryCountsCache[request] = counts; - _trimQueueLibraryCaches(); + _trimQueueLibraryCountsCache(); return counts; }, orElse: () => @@ -356,28 +370,72 @@ class _QueueTabState extends ConsumerState { AsyncValue<_QueueLibraryPageData>? value, _QueueLibraryPageRequest request, ) { - if (value == null) { - return _queueLibraryPageDataCache[request] ?? - const _QueueLibraryPageData(); + if (value != null) { + value.whenOrNull( + data: (data) { + _queueLibraryPageDataCache[request] = data; + _trimQueueLibraryPageDataCache(protectedRequest: request); + }, + ); } - return value.maybeWhen( - data: (data) { - _queueLibraryPageDataCache[request] = data; - _trimQueueLibraryCaches(); - return data; - }, - orElse: () => - _queueLibraryPageDataCache[request] ?? const _QueueLibraryPageData(), - ); + + final pages = <_QueueLibraryPageData>[]; + for (var offset = 0; offset <= request.offset; offset += _libraryPageSize) { + final page = + _queueLibraryPageDataCache[_QueueLibraryPageRequest( + filterMode: request.filterMode, + limit: request.limit, + offset: offset, + searchQuery: request.searchQuery, + filterSource: request.filterSource, + filterQuality: request.filterQuality, + filterFormat: request.filterFormat, + filterMetadata: request.filterMetadata, + sortMode: request.sortMode, + localLibraryEnabled: request.localLibraryEnabled, + )]; + if (page != null) pages.add(page); + } + + return _QueueLibraryPageData.combine(pages); } - void _trimQueueLibraryCaches() { - const maxEntries = 24; - while (_queueLibraryCountsCache.length > maxEntries) { + void _trimQueueLibraryCountsCache() { + const maxCountEntries = 24; + while (_queueLibraryCountsCache.length > maxCountEntries) { _queueLibraryCountsCache.remove(_queueLibraryCountsCache.keys.first); } - while (_queueLibraryPageDataCache.length > maxEntries) { - _queueLibraryPageDataCache.remove(_queueLibraryPageDataCache.keys.first); + } + + bool _isProtectedQueueLibraryPage( + _QueueLibraryPageRequest request, + _QueueLibraryPageRequest protectedRequest, + ) { + return request.filterMode == protectedRequest.filterMode && + request.limit == protectedRequest.limit && + request.offset <= protectedRequest.offset && + request.searchQuery == protectedRequest.searchQuery && + request.filterSource == protectedRequest.filterSource && + request.filterQuality == protectedRequest.filterQuality && + request.filterFormat == protectedRequest.filterFormat && + request.filterMetadata == protectedRequest.filterMetadata && + request.sortMode == protectedRequest.sortMode && + request.localLibraryEnabled == protectedRequest.localLibraryEnabled; + } + + void _trimQueueLibraryPageDataCache({ + required _QueueLibraryPageRequest protectedRequest, + }) { + const maxPageEntries = 96; + while (_queueLibraryPageDataCache.length > maxPageEntries) { + final removableKey = _queueLibraryPageDataCache.keys + .where( + (request) => + !_isProtectedQueueLibraryPage(request, protectedRequest), + ) + .firstOrNull; + if (removableKey == null) break; + _queueLibraryPageDataCache.remove(removableKey); } } @@ -399,7 +457,10 @@ class _QueueTabState extends ConsumerState { metrics.extentAfter <= metrics.viewportDimension * 1.5; if (!nearEnd) return false; - _loadMoreLibraryItems(hasMoreLibrary: hasMoreLibrary); + _loadMoreLibraryItems( + filterMode: filterMode, + hasMoreLibrary: hasMoreLibrary, + ); return false; } @@ -783,6 +844,12 @@ class _QueueTabState extends ConsumerState { } void _animateToFilterPage(int index) { + if (index >= 0 && index < _filterModes.length) { + final filterMode = _filterModes[index]; + if (ref.read(settingsProvider).historyFilterMode != filterMode) { + ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); + } + } _filterPageController?.animateToPage( index, duration: const Duration(milliseconds: 300), @@ -1379,8 +1446,6 @@ class _QueueTabState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { _embeddedCoverRefreshScheduled = false; if (mounted) { - // Increment version to trigger ValueListenableBuilder rebuilds - // on cover images only, instead of rebuilding the entire widget tree. _embeddedCoverVersion.value++; } }); @@ -1426,7 +1491,7 @@ class _QueueTabState extends ConsumerState { _filterFormat = null; _filterMetadata = null; _sortMode = 'latest'; - _libraryPageLimit = _libraryPageSize; + _resetLibraryPaging(); _unifiedItemsCache.clear(); _invalidateFilterContentCache(); }); @@ -2064,7 +2129,7 @@ class _QueueTabState extends ConsumerState { _filterFormat = tempFormat; _filterMetadata = tempMetadata; _sortMode = tempSortMode; - _libraryPageLimit = _libraryPageSize; + _resetLibraryPaging(); _unifiedItemsCache.clear(); _invalidateFilterContentCache(); }); @@ -2668,7 +2733,8 @@ class _QueueTabState extends ConsumerState { _QueueLibraryPageRequest pageRequest(String filterMode) => _QueueLibraryPageRequest( filterMode: filterMode, - limit: _libraryPageLimit, + limit: _libraryPageSize, + offset: _libraryPageOffsetFor(filterMode), searchQuery: _searchQuery, filterSource: _filterSource, filterQuality: _filterQuality, @@ -2678,19 +2744,20 @@ class _QueueTabState extends ConsumerState { localLibraryEnabled: localLibraryEnabled, ); - final pageRequests = { - for (final mode in _filterModes) mode: pageRequest(mode), - }; - final pageValues = >{ - for (final entry in pageRequests.entries) - entry.key: ref.watch(_queueLibraryPageProvider(entry.value)), - }; + final activePageRequest = pageRequest(historyFilterMode); + final activePageValue = ref.watch( + _queueLibraryPageProvider(activePageRequest), + ); - _QueueLibraryPageData pageData(String filterMode) => - _resolveQueueLibraryPageData( - pageValues[filterMode], - pageRequests[filterMode]!, - ); + _QueueLibraryPageData pageData(String filterMode) { + final request = filterMode == historyFilterMode + ? activePageRequest + : pageRequest(filterMode); + return _resolveQueueLibraryPageData( + filterMode == historyFilterMode ? activePageValue : null, + request, + ); + } _FilterContentData getFilterData(String filterMode) { return pageData(filterMode).toFilterContentData( @@ -2716,8 +2783,7 @@ class _QueueTabState extends ConsumerState { }; final hasMoreLibrary = currentLoadedCount < currentTotalCount; final isLibraryPageLoading = - countsValue.isLoading || - (pageValues[historyFilterMode]?.isLoading ?? false); + countsValue.isLoading || activePageValue.isLoading; final hasAnyLibraryItems = queueCounts.allTrackCount > 0 || queueCounts.albumCount > 0; final hasLibraryContent = @@ -3388,9 +3454,9 @@ class _QueueTabState extends ConsumerState { item.track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -4048,6 +4114,10 @@ class _QueueTabState extends ConsumerState { } final leadCount = activeDownloadIds.length + bridgeIds.length; + final collectionEntries = filterMode == 'all' + ? _getVisibleCollectionEntries(collectionState) + : const <_CollectionEntry>[]; + final collectionCount = collectionEntries.length; Widget leadGridCell(int index) { if (index < activeDownloadIds.length) { @@ -4270,10 +4340,6 @@ class _QueueTabState extends ConsumerState { childAspectRatio: 0.66, delegate: SliverChildBuilderDelegate( (context, index) { - final collectionEntries = _getVisibleCollectionEntries( - collectionState, - ); - final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabGridCollectionItem( context: context, @@ -4332,9 +4398,7 @@ class _QueueTabState extends ConsumerState { return const SizedBox.shrink(); }, childCount: - leadCount + - _getVisibleCollectionEntries(collectionState).length + - filteredUnifiedItems.length, + leadCount + collectionCount + filteredUnifiedItems.length, ), ), ) @@ -4342,10 +4406,6 @@ class _QueueTabState extends ConsumerState { SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - final collectionEntries = _getVisibleCollectionEntries( - collectionState, - ); - final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabListCollectionItem( context: context, @@ -4403,9 +4463,7 @@ class _QueueTabState extends ConsumerState { return const SizedBox.shrink(); }, childCount: - leadCount + - _getVisibleCollectionEntries(collectionState).length + - filteredUnifiedItems.length, + leadCount + collectionCount + filteredUnifiedItems.length, ), ), ], @@ -5467,8 +5525,7 @@ class _QueueTabState extends ConsumerState { int successCount = 0; final total = selectedItems.length; final historyDb = HistoryDatabase.instance; - final newQuality = - isLosslessConversionTarget(targetFormat) + final newQuality = isLosslessConversionTarget(targetFormat) ? '${targetFormat.toUpperCase()} Lossless' : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); @@ -5769,9 +5826,7 @@ class _QueueTabState extends ConsumerState { } /// Batch-scan loudness and write ReplayGain tags to the selected tracks. - Future _runBatchReplayGain( - List allItems, - ) async { + Future _runBatchReplayGain(List allItems) async { final itemsById = {for (final item in allItems) item.id: item}; final selectedItems = []; for (final id in _selectedIds) { @@ -5859,9 +5914,7 @@ class _QueueTabState extends ConsumerState { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.replayGainBatchSuccess(successCount, total), - ), + content: Text(context.l10n.replayGainBatchSuccess(successCount, total)), ), ); } @@ -6165,76 +6218,80 @@ class _QueueTabState extends ConsumerState { padding: const EdgeInsets.all(12), child: Row( children: [ - isCompleted - ? Hero( - tag: 'cover_${item.id}', - child: _buildCoverArt(item, colorScheme), - ) - : _buildCoverArt(item, colorScheme), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.track.artistName, - artistId: item.track.artistId, - coverUrl: item.track.coverUrl, - extensionId: item.track.source, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - if (item.status == DownloadStatus.downloading) ...[ - const SizedBox(height: 5), - Row( - children: [ - Icon( - Icons.download_rounded, - size: 12, - color: colorScheme.primary, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - _formatDownloadStatusLine(context, item), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), + isCompleted + ? Hero( + tag: 'cover_${item.id}', + child: _buildCoverArt(item, colorScheme), + ) + : _buildCoverArt(item, colorScheme), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.track.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: item.track.artistName, + artistId: item.track.artistId, + coverUrl: item.track.coverUrl, + extensionId: item.track.source, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (item.status == DownloadStatus.downloading) ...[ + const SizedBox(height: 5), + Row( + children: [ + Icon( + Icons.download_rounded, + size: 12, + color: colorScheme.primary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + _formatDownloadStatusLine(context, item), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), ], - ), - ], - if (item.status == DownloadStatus.failed) ...[ - const SizedBox(height: 4), - _buildDownloadFailureMessage( - context, - item, - colorScheme, - ), - ], - ], - ), + if (item.status == DownloadStatus.failed) ...[ + const SizedBox(height: 4), + _buildDownloadFailureMessage( + context, + item, + colorScheme, + ), + ], + ], + ), + ), + const SizedBox(width: 8), + _buildActionButtons(context, item, colorScheme), + ], ), - const SizedBox(width: 8), - _buildActionButtons(context, item, colorScheme), - ], - ), - ), + ), ], ), ), diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index d89d3f21..ff81a82f 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -276,6 +276,7 @@ class _FilterContentData { class _QueueLibraryPageRequest { final String filterMode; final int limit; + final int offset; final String searchQuery; final String? filterSource; final String? filterQuality; @@ -287,6 +288,7 @@ class _QueueLibraryPageRequest { const _QueueLibraryPageRequest({ required this.filterMode, required this.limit, + required this.offset, required this.searchQuery, required this.filterSource, required this.filterQuality, @@ -298,6 +300,7 @@ class _QueueLibraryPageRequest { QueueLibraryDbQuery toDbQuery() => QueueLibraryDbQuery( limit: limit, + offset: offset, filterMode: filterMode, searchQuery: searchQuery, source: filterSource, @@ -314,6 +317,7 @@ class _QueueLibraryPageRequest { other is _QueueLibraryPageRequest && filterMode == other.filterMode && limit == other.limit && + offset == other.offset && searchQuery == other.searchQuery && filterSource == other.filterSource && filterQuality == other.filterQuality && @@ -326,6 +330,7 @@ class _QueueLibraryPageRequest { int get hashCode => Object.hash( filterMode, limit, + offset, searchQuery, filterSource, filterQuality, @@ -399,6 +404,33 @@ class _QueueLibraryPageData { this.groupedLocalAlbums = const [], }); + factory _QueueLibraryPageData.combine(List<_QueueLibraryPageData> pages) { + if (pages.isEmpty) return const _QueueLibraryPageData(); + if (pages.length == 1) return pages.first; + + final items = []; + final historyItems = []; + final localItems = []; + final groupedAlbums = <_GroupedAlbum>[]; + final groupedLocalAlbums = <_GroupedLocalAlbum>[]; + + for (final page in pages) { + items.addAll(page.items); + historyItems.addAll(page.historyItems); + localItems.addAll(page.localItems); + groupedAlbums.addAll(page.groupedAlbums); + groupedLocalAlbums.addAll(page.groupedLocalAlbums); + } + + return _QueueLibraryPageData( + items: items, + historyItems: historyItems, + localItems: localItems, + groupedAlbums: groupedAlbums, + groupedLocalAlbums: groupedLocalAlbums, + ); + } + _FilterContentData toFilterContentData( LibraryCollectionsState collectionState, { int? totalTrackCount, diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 4585b75f..34306287 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -902,11 +902,6 @@ class LibraryDatabase { ), allArgs, ); - final allRows = await db.rawQuery( - 'SELECT COUNT(*) AS count FROM ($allSql)', - allArgs, - ); - final singleArgs = []; final singleSql = _queueTrackUnionSql( QueueLibraryDbQuery( @@ -923,22 +918,25 @@ class LibraryDatabase { ), singleArgs, ); - final singleRows = await db.rawQuery( - 'SELECT COUNT(*) AS count FROM ($singleSql)', - singleArgs, - ); final albumArgs = []; final albumSql = _queueAlbumUnionSql(request, albumArgs); - final albumRows = await db.rawQuery( - 'SELECT COUNT(*) AS count FROM ($albumSql)', - albumArgs, + + final rows = await db.rawQuery( + ''' + SELECT + (SELECT COUNT(*) FROM ($allSql)) AS all_count, + (SELECT COUNT(*) FROM ($singleSql)) AS single_count, + (SELECT COUNT(*) FROM ($albumSql)) AS album_count + ''', + [...allArgs, ...singleArgs, ...albumArgs], ); + final row = rows.isNotEmpty ? rows.first : const {}; return QueueLibraryCounts( - allTrackCount: Sqflite.firstIntValue(allRows) ?? 0, - albumCount: Sqflite.firstIntValue(albumRows) ?? 0, - singleTrackCount: Sqflite.firstIntValue(singleRows) ?? 0, + allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0, + albumCount: (row['album_count'] as num?)?.toInt() ?? 0, + singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0, ); }