mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 16:37:17 +02:00
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:
@@ -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
@@ -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
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user