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