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
This commit is contained in:
zarzet
2026-06-26 22:39:22 +07:00
parent 58e615462c
commit c1c0494912
5 changed files with 464 additions and 195 deletions
+31 -17
View File
@@ -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)
}
+196 -28
View File
@@ -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()
+192 -135
View File
@@ -227,7 +227,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
String _sortMode = 'latest';
double _libraryGridExtent = _libraryGridDefaultExtent;
double? _libraryGridScaleStartExtent;
int _libraryPageLimit = _libraryPageSize;
final Map<String, int> _libraryPageOffsetByFilter = {};
bool _libraryPageLoadScheduled = false;
final Map<_QueueLibraryCountsRequest, QueueLibraryCounts>
_queueLibraryCountsCache = {};
@@ -305,7 +305,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (!mounted || _searchQuery == normalized) return;
setState(() {
_searchQuery = normalized;
_libraryPageLimit = _libraryPageSize;
_resetLibraryPaging();
});
_requestFilterRefresh();
});
@@ -316,16 +316,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
return value.maybeWhen(
data: (counts) {
_queueLibraryCountsCache[request] = counts;
_trimQueueLibraryCaches();
_trimQueueLibraryCountsCache();
return counts;
},
orElse: () =>
@@ -356,28 +370,72 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
}
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<QueueTab> {
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<QueueTab> {
_filterFormat = null;
_filterMetadata = null;
_sortMode = 'latest';
_libraryPageLimit = _libraryPageSize;
_resetLibraryPaging();
_unifiedItemsCache.clear();
_invalidateFilterContentCache();
});
@@ -2064,7 +2129,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_filterFormat = tempFormat;
_filterMetadata = tempMetadata;
_sortMode = tempSortMode;
_libraryPageLimit = _libraryPageSize;
_resetLibraryPaging();
_unifiedItemsCache.clear();
_invalidateFilterContentCache();
});
@@ -2668,7 +2733,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_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<QueueTab> {
localLibraryEnabled: localLibraryEnabled,
);
final pageRequests = <String, _QueueLibraryPageRequest>{
for (final mode in _filterModes) mode: pageRequest(mode),
};
final pageValues = <String, AsyncValue<_QueueLibraryPageData>>{
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<QueueTab> {
};
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<QueueTab> {
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<QueueTab> {
}
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<QueueTab> {
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<QueueTab> {
return const SizedBox.shrink();
},
childCount:
leadCount +
_getVisibleCollectionEntries(collectionState).length +
filteredUnifiedItems.length,
leadCount + collectionCount + filteredUnifiedItems.length,
),
),
)
@@ -4342,10 +4406,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
return const SizedBox.shrink();
},
childCount:
leadCount +
_getVisibleCollectionEntries(collectionState).length +
filteredUnifiedItems.length,
leadCount + collectionCount + filteredUnifiedItems.length,
),
),
],
@@ -5467,8 +5525,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
}
/// Batch-scan loudness and write ReplayGain tags to the selected tracks.
Future<void> _runBatchReplayGain(
List<UnifiedLibraryItem> allItems,
) async {
Future<void> _runBatchReplayGain(List<UnifiedLibraryItem> allItems) async {
final itemsById = {for (final item in allItems) item.id: item};
final selectedItems = <UnifiedLibraryItem>[];
for (final id in _selectedIds) {
@@ -5859,9 +5914,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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),
],
),
),
),
],
),
),
+32
View File
@@ -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 = <UnifiedLibraryItem>[];
final historyItems = <DownloadHistoryItem>[];
final localItems = <LocalLibraryItem>[];
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,
+13 -15
View File
@@ -902,11 +902,6 @@ class LibraryDatabase {
),
allArgs,
);
final allRows = await db.rawQuery(
'SELECT COUNT(*) AS count FROM ($allSql)',
allArgs,
);
final singleArgs = <Object?>[];
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 = <Object?>[];
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 <String, Object?>{};
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,
);
}