mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 20:57:57 +02:00
perf: reduce library and queue update overhead
This commit is contained in:
@@ -90,7 +90,7 @@ type scannedCueFileInfo struct {
|
||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||
var files []libraryAudioFileInfo
|
||||
|
||||
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
err := filepath.WalkDir(folderPath, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
default:
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
files = append(files, libraryAudioFileInfo{
|
||||
path: path,
|
||||
modTime: info.ModTime().UnixMilli(),
|
||||
|
||||
@@ -1293,19 +1293,11 @@ class DownloadQueueState {
|
||||
);
|
||||
}
|
||||
|
||||
int get queuedCount => items
|
||||
.where(
|
||||
(i) =>
|
||||
i.status == DownloadStatus.queued ||
|
||||
i.status == DownloadStatus.downloading,
|
||||
)
|
||||
.length;
|
||||
int get completedCount =>
|
||||
items.where((i) => i.status == DownloadStatus.completed).length;
|
||||
int get failedCount =>
|
||||
items.where((i) => i.status == DownloadStatus.failed).length;
|
||||
int get queuedCount => items.isEmpty ? 0 : lookup.queuedCount;
|
||||
int get completedCount => items.isEmpty ? 0 : lookup.completedCount;
|
||||
int get failedCount => items.isEmpty ? 0 : lookup.failedCount;
|
||||
int get activeDownloadsCount =>
|
||||
items.where((i) => i.status == DownloadStatus.downloading).length;
|
||||
items.isEmpty ? 0 : lookup.activeDownloadsCount;
|
||||
}
|
||||
|
||||
class _ProgressUpdate {
|
||||
@@ -6419,18 +6411,30 @@ class DownloadQueueLookup {
|
||||
final Map<String, DownloadItem> byItemId;
|
||||
final Map<String, int> indexByItemId;
|
||||
final List<String> itemIds;
|
||||
final int queuedCount;
|
||||
final int completedCount;
|
||||
final int failedCount;
|
||||
final int activeDownloadsCount;
|
||||
|
||||
const DownloadQueueLookup.empty()
|
||||
: byTrackId = const {},
|
||||
byItemId = const {},
|
||||
indexByItemId = const {},
|
||||
itemIds = const [];
|
||||
itemIds = const [],
|
||||
queuedCount = 0,
|
||||
completedCount = 0,
|
||||
failedCount = 0,
|
||||
activeDownloadsCount = 0;
|
||||
|
||||
DownloadQueueLookup._({
|
||||
required this.byTrackId,
|
||||
required this.byItemId,
|
||||
required this.indexByItemId,
|
||||
required this.itemIds,
|
||||
required this.queuedCount,
|
||||
required this.completedCount,
|
||||
required this.failedCount,
|
||||
required this.activeDownloadsCount,
|
||||
});
|
||||
|
||||
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
|
||||
@@ -6438,21 +6442,47 @@ class DownloadQueueLookup {
|
||||
final byItemId = <String, DownloadItem>{};
|
||||
final indexByItemId = <String, int>{};
|
||||
final itemIds = <String>[];
|
||||
var queuedCount = 0;
|
||||
var completedCount = 0;
|
||||
var failedCount = 0;
|
||||
var activeDownloadsCount = 0;
|
||||
for (var index = 0; index < items.length; index++) {
|
||||
final item = items[index];
|
||||
byTrackId.putIfAbsent(item.track.id, () => item);
|
||||
byItemId[item.id] = item;
|
||||
indexByItemId[item.id] = index;
|
||||
itemIds.add(item.id);
|
||||
if (_countsAsQueued(item.status)) queuedCount++;
|
||||
if (item.status == DownloadStatus.completed) completedCount++;
|
||||
if (item.status == DownloadStatus.failed) failedCount++;
|
||||
if (item.status == DownloadStatus.downloading) activeDownloadsCount++;
|
||||
}
|
||||
return DownloadQueueLookup._(
|
||||
byTrackId: byTrackId,
|
||||
byItemId: byItemId,
|
||||
indexByItemId: indexByItemId,
|
||||
itemIds: itemIds,
|
||||
queuedCount: queuedCount,
|
||||
completedCount: completedCount,
|
||||
failedCount: failedCount,
|
||||
activeDownloadsCount: activeDownloadsCount,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _countsAsQueued(DownloadStatus status) =>
|
||||
status == DownloadStatus.queued || status == DownloadStatus.downloading;
|
||||
|
||||
static int _deltaForStatus({
|
||||
required DownloadStatus previous,
|
||||
required DownloadStatus next,
|
||||
required bool Function(DownloadStatus status) predicate,
|
||||
}) {
|
||||
final had = predicate(previous);
|
||||
final has = predicate(next);
|
||||
if (had == has) return 0;
|
||||
return has ? 1 : -1;
|
||||
}
|
||||
|
||||
DownloadQueueLookup updatedForIndices({
|
||||
required List<DownloadItem> previousItems,
|
||||
required List<DownloadItem> nextItems,
|
||||
@@ -6473,7 +6503,11 @@ class DownloadQueueLookup {
|
||||
}
|
||||
if (normalizedChanged.isEmpty) return this;
|
||||
|
||||
final nextByItemId = Map<String, DownloadItem>.from(byItemId);
|
||||
var nextQueuedCount = queuedCount;
|
||||
var nextCompletedCount = completedCount;
|
||||
var nextFailedCount = failedCount;
|
||||
var nextActiveDownloadsCount = activeDownloadsCount;
|
||||
Map<String, DownloadItem>? nextByItemId;
|
||||
Map<String, DownloadItem>? nextByTrackId;
|
||||
|
||||
for (final index in normalizedChanged) {
|
||||
@@ -6483,18 +6517,43 @@ class DownloadQueueLookup {
|
||||
return DownloadQueueLookup.fromItems(nextItems);
|
||||
}
|
||||
|
||||
nextByItemId ??= Map<String, DownloadItem>.from(byItemId);
|
||||
nextByItemId[next.id] = next;
|
||||
if (byTrackId[next.track.id]?.id == previous.id) {
|
||||
nextByTrackId ??= Map<String, DownloadItem>.from(byTrackId);
|
||||
nextByTrackId[next.track.id] = next;
|
||||
}
|
||||
nextQueuedCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: _countsAsQueued,
|
||||
);
|
||||
nextCompletedCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.completed,
|
||||
);
|
||||
nextFailedCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.failed,
|
||||
);
|
||||
nextActiveDownloadsCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.downloading,
|
||||
);
|
||||
}
|
||||
|
||||
return DownloadQueueLookup._(
|
||||
byTrackId: nextByTrackId ?? byTrackId,
|
||||
byItemId: nextByItemId,
|
||||
byItemId: nextByItemId ?? byItemId,
|
||||
indexByItemId: indexByItemId,
|
||||
itemIds: itemIds,
|
||||
queuedCount: nextQueuedCount,
|
||||
completedCount: nextCompletedCount,
|
||||
failedCount: nextFailedCount,
|
||||
activeDownloadsCount: nextActiveDownloadsCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
Timer? _progressStreamBootstrapTimer;
|
||||
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
||||
bool _isLoaded = false;
|
||||
bool _hasLoadedFromDatabase = false;
|
||||
Future<void>? _loadFuture;
|
||||
bool _scanCancelRequested = false;
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
@@ -148,14 +150,22 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_progressStreamSub?.cancel();
|
||||
});
|
||||
|
||||
Future.microtask(() async {
|
||||
await _loadFromDatabase();
|
||||
});
|
||||
Future.microtask(_ensureLoadedFromDatabase);
|
||||
return LocalLibraryState();
|
||||
}
|
||||
|
||||
Future<void> _ensureLoadedFromDatabase() {
|
||||
if (_hasLoadedFromDatabase) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
return _loadFuture ??= _loadFromDatabase();
|
||||
}
|
||||
|
||||
Future<void> _loadFromDatabase() async {
|
||||
if (_isLoaded) return;
|
||||
if (_hasLoadedFromDatabase) return;
|
||||
if (_isLoaded) {
|
||||
return _loadFuture ?? Future<void>.value();
|
||||
}
|
||||
_isLoaded = true;
|
||||
|
||||
try {
|
||||
@@ -186,14 +196,20 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'Loaded ${items.length} items from library database, lastScannedAt: '
|
||||
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
|
||||
);
|
||||
_hasLoadedFromDatabase = true;
|
||||
} catch (e, stack) {
|
||||
_isLoaded = false;
|
||||
_log.e('Failed to load library from database: $e', e, stack);
|
||||
} finally {
|
||||
_loadFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadFromStorage() async {
|
||||
_isLoaded = false;
|
||||
await _loadFromDatabase();
|
||||
_hasLoadedFromDatabase = false;
|
||||
_loadFuture = null;
|
||||
await _ensureLoadedFromDatabase();
|
||||
}
|
||||
|
||||
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
|
||||
@@ -209,6 +225,31 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<Map<String, LocalLibraryItem>> _currentItemsByPathForIncrementalScan(
|
||||
Map<String, int> existingFiles,
|
||||
) async {
|
||||
await _ensureLoadedFromDatabase();
|
||||
|
||||
final loadedItems = state.items;
|
||||
if (loadedItems.isNotEmpty || existingFiles.isEmpty) {
|
||||
return <String, LocalLibraryItem>{
|
||||
for (final item in loadedItems) item.filePath: item,
|
||||
};
|
||||
}
|
||||
|
||||
// Rare fallback: if provider state failed to warm while the database has
|
||||
// rows, preserve correctness instead of applying a diff to an empty base.
|
||||
_log.w(
|
||||
'Library state is empty while database has ${existingFiles.length} files; '
|
||||
'loading incremental scan baseline from database',
|
||||
);
|
||||
final existingJson = await _db.getAll();
|
||||
return <String, LocalLibraryItem>{
|
||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||
item.filePath: item,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> startScan(
|
||||
String folderPath, {
|
||||
bool forceFullScan = false,
|
||||
@@ -456,11 +497,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
final existingJson = await _db.getAll();
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||
item.filePath: item,
|
||||
};
|
||||
final currentByPath = await _currentItemsByPathForIncrementalScan(
|
||||
existingFiles,
|
||||
);
|
||||
final existingDownloadedPaths = <String>[];
|
||||
currentByPath.removeWhere((path, _) {
|
||||
final shouldExclude = _isDownloadedPath(path, downloadedPathKeys);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
@@ -8,6 +9,8 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('PlatformBridge');
|
||||
|
||||
Object? _decodeJsonInBackground(String json) => jsonDecode(json);
|
||||
|
||||
class _BridgeCacheEntry {
|
||||
final Map<String, dynamic> value;
|
||||
final DateTime expiresAt;
|
||||
@@ -32,6 +35,7 @@ class _BridgeInFlight<T> {
|
||||
class PlatformBridge {
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
static const _jsonResultFileKey = '__json_file';
|
||||
static const _backgroundJsonDecodeThresholdBytes = 128 * 1024;
|
||||
static const _metadataCacheTtl = Duration(minutes: 20);
|
||||
static const _availabilityCacheTtl = Duration(minutes: 15);
|
||||
static const _bridgeCacheMaxEntries = 256;
|
||||
@@ -1568,16 +1572,27 @@ class PlatformBridge {
|
||||
try {
|
||||
final contents = await file.readAsString();
|
||||
if (contents.isEmpty) return null;
|
||||
return jsonDecode(contents);
|
||||
return _decodeJsonStringAsync(contents);
|
||||
} finally {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (result is String &&
|
||||
result.length >= _backgroundJsonDecodeThresholdBytes) {
|
||||
return _decodeJsonStringAsync(result);
|
||||
}
|
||||
return _decodeJsonResult(result);
|
||||
}
|
||||
|
||||
static Future<Object?> _decodeJsonStringAsync(String json) {
|
||||
if (json.length < _backgroundJsonDecodeThresholdBytes) {
|
||||
return Future<Object?>.value(jsonDecode(json));
|
||||
}
|
||||
return compute(_decodeJsonInBackground, json);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _decodeRequiredMapResult(
|
||||
dynamic result,
|
||||
String method,
|
||||
|
||||
Reference in New Issue
Block a user