refactor: migrate queue_tab cover resolver to shared service, add supporter

This commit is contained in:
zarzet
2026-02-11 12:28:50 +07:00
parent 9b27e86e0f
commit abc599d7f9
3 changed files with 54 additions and 226 deletions
+24 -166
View File
@@ -14,7 +14,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
@@ -279,7 +279,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final Set<String> _pendingChecks = {};
static const int _maxCacheSize = 500;
static const int _maxSearchIndexCacheSize = 4000;
static const int _maxDownloadedEmbeddedCoverCacheSize = 180;
bool _embeddedCoverRefreshScheduled = false;
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
@@ -311,10 +311,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_HistoryStats? _historyStatsCache;
final Map<String, String> _searchIndexCache = {};
final Map<String, String> _localSearchIndexCache = {};
final Map<String, String> _downloadedEmbeddedCoverCache = {};
final Set<String> _pendingDownloadedCoverExtract = {};
final Set<String> _pendingDownloadedCoverRefresh = {};
final Set<String> _failedDownloadedCoverExtract = {};
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
List<DownloadHistoryItem>? _filterItemsCache;
String _filterQueryCache = '';
@@ -361,13 +357,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
@override
void dispose() {
for (final coverPath in _downloadedEmbeddedCoverCache.values) {
_cleanupTempCoverPathSync(coverPath);
}
_downloadedEmbeddedCoverCache.clear();
_pendingDownloadedCoverExtract.clear();
_pendingDownloadedCoverRefresh.clear();
_failedDownloadedCoverExtract.clear();
for (final notifier in _fileExistsNotifiers.values) {
notifier.dispose();
}
@@ -425,12 +414,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
.map((item) => _cleanFilePath(item.filePath))
.where((path) => path.isNotEmpty)
.toSet();
final staleKeys = _downloadedEmbeddedCoverCache.keys
.where((path) => !validPaths.contains(path))
.toList(growable: false);
for (final key in staleKeys) {
_invalidateDownloadedEmbeddedCover(key);
}
DownloadedEmbeddedCoverResolver.invalidatePathsNotIn(validPaths);
}
_requestFilterRefresh();
}
@@ -794,69 +778,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
/// Strip EXISTS: prefix from file path (legacy history items)
String _cleanFilePath(String? filePath) {
if (filePath == null) return '';
if (filePath.startsWith('EXISTS:')) {
return filePath.substring(7);
}
return filePath;
}
void _cleanupTempCoverPathSync(String? coverPath) {
if (coverPath == null || coverPath.isEmpty) return;
try {
final file = File(coverPath);
if (file.existsSync()) {
file.deleteSync();
}
final parent = file.parent;
if (parent.existsSync()) {
parent.deleteSync(recursive: true);
}
} catch (_) {}
}
void _invalidateDownloadedEmbeddedCover(String? filePath) {
final cleanPath = _cleanFilePath(filePath);
if (cleanPath.isEmpty) return;
final cachedPath = _downloadedEmbeddedCoverCache.remove(cleanPath);
_pendingDownloadedCoverExtract.remove(cleanPath);
_pendingDownloadedCoverRefresh.remove(cleanPath);
_failedDownloadedCoverExtract.remove(cleanPath);
_cleanupTempCoverPathSync(cachedPath);
}
void _trimDownloadedEmbeddedCoverCache() {
while (_downloadedEmbeddedCoverCache.length >
_maxDownloadedEmbeddedCoverCacheSize) {
final oldestKey = _downloadedEmbeddedCoverCache.keys.first;
final removedPath = _downloadedEmbeddedCoverCache.remove(oldestKey);
_pendingDownloadedCoverExtract.remove(oldestKey);
_pendingDownloadedCoverRefresh.remove(oldestKey);
_failedDownloadedCoverExtract.remove(oldestKey);
_cleanupTempCoverPathSync(removedPath);
}
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
}
Future<int?> _readFileModTimeMillis(String? filePath) async {
final cleanPath = _cleanFilePath(filePath);
if (cleanPath.isEmpty) return null;
return DownloadedEmbeddedCoverResolver.readFileModTimeMillis(filePath);
}
if (cleanPath.startsWith('content://')) {
try {
final modTimes = await PlatformBridge.getSafFileModTimes([cleanPath]);
return modTimes[cleanPath];
} catch (_) {
return null;
void _onEmbeddedCoverChanged() {
if (!mounted || _embeddedCoverRefreshScheduled) return;
_embeddedCoverRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_embeddedCoverRefreshScheduled = false;
if (mounted) {
setState(() {});
}
}
try {
final stat = await File(cleanPath).stat();
return stat.modified.millisecondsSinceEpoch;
} catch (_) {
return null;
}
});
}
Future<void> _scheduleDownloadedEmbeddedCoverRefreshForPath(
@@ -864,98 +801,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
int? beforeModTime,
bool force = false,
}) async {
final cleanPath = _cleanFilePath(filePath);
if (cleanPath.isEmpty) return;
if (!force) {
if (beforeModTime == null) {
return;
}
final afterModTime = await _readFileModTimeMillis(cleanPath);
if (afterModTime != null && afterModTime == beforeModTime) {
return;
}
}
_pendingDownloadedCoverRefresh.add(cleanPath);
_failedDownloadedCoverExtract.remove(cleanPath);
if (mounted) {
setState(() {});
}
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
filePath,
beforeModTime: beforeModTime,
force: force,
onChanged: _onEmbeddedCoverChanged,
);
}
String? _resolveDownloadedEmbeddedCoverPath(String? filePath) {
final cleanPath = _cleanFilePath(filePath);
if (cleanPath.isEmpty) return null;
if (_pendingDownloadedCoverRefresh.remove(cleanPath)) {
_ensureDownloadedEmbeddedCover(cleanPath, forceRefresh: true);
}
final cachedPath = _downloadedEmbeddedCoverCache[cleanPath];
if (cachedPath != null) {
if (File(cachedPath).existsSync()) {
return cachedPath;
}
_downloadedEmbeddedCoverCache.remove(cleanPath);
_cleanupTempCoverPathSync(cachedPath);
}
return null;
}
void _ensureDownloadedEmbeddedCover(
String cleanPath, {
bool forceRefresh = false,
}) {
if (cleanPath.isEmpty) return;
if (_pendingDownloadedCoverExtract.contains(cleanPath)) return;
if (!forceRefresh && _downloadedEmbeddedCoverCache.containsKey(cleanPath)) {
return;
}
if (!forceRefresh && _failedDownloadedCoverExtract.contains(cleanPath)) {
return;
}
_pendingDownloadedCoverExtract.add(cleanPath);
Future.microtask(() async {
String? outputPath;
try {
final tempDir = await Directory.systemTemp.createTemp('library_cover_');
outputPath = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
final result = await PlatformBridge.extractCoverToFile(
cleanPath,
outputPath,
);
final hasCover =
result['error'] == null && await File(outputPath).exists();
if (!hasCover) {
_failedDownloadedCoverExtract.add(cleanPath);
_cleanupTempCoverPathSync(outputPath);
return;
}
if (!mounted) {
_cleanupTempCoverPathSync(outputPath);
return;
}
final previous = _downloadedEmbeddedCoverCache[cleanPath];
_downloadedEmbeddedCoverCache[cleanPath] = outputPath;
_failedDownloadedCoverExtract.remove(cleanPath);
_trimDownloadedEmbeddedCoverCache();
if (previous != null && previous != outputPath) {
_cleanupTempCoverPathSync(previous);
}
setState(() {});
} catch (_) {
_failedDownloadedCoverExtract.add(cleanPath);
_cleanupTempCoverPathSync(outputPath);
} finally {
_pendingDownloadedCoverExtract.remove(cleanPath);
}
});
return DownloadedEmbeddedCoverResolver.resolve(
filePath,
onChanged: _onEmbeddedCoverChanged,
);
}
ValueListenable<bool> _fileExistsListenable(String? filePath) {
+2 -1
View File
@@ -204,8 +204,9 @@ class _RecentDonorsCard extends StatelessWidget {
_DonorTile(name: 'Julian', colorScheme: colorScheme),
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
_DonorTile(
name: '283Fabio',
name: 'Elias el Autentico',
colorScheme: colorScheme,
showDivider: false,
),
@@ -19,18 +19,13 @@ class _EmbeddedCoverCacheEntry {
/// It keeps a bounded in-memory cache and only refreshes extraction
/// when the source file changed.
class DownloadedEmbeddedCoverResolver {
static const int _maxCacheEntries = 160;
static const int _minModCheckIntervalMs = 1200;
static const int _minPreviewExistsCheckIntervalMs = 2200;
static const int _maxCacheEntries = 180;
static final LinkedHashMap<String, _EmbeddedCoverCacheEntry> _cache =
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
static final Set<String> _pendingExtract = <String>{};
static final Set<String> _pendingModCheck = <String>{};
static final Set<String> _pendingRefresh = <String>{};
static final Set<String> _failedExtract = <String>{};
static final Map<String, int> _lastModCheckMillis = <String, int>{};
static final Map<String, int> _lastPreviewExistsCheckMillis =
<String, int>{};
static String cleanFilePath(String? filePath) {
if (filePath == null) return '';
@@ -65,27 +60,20 @@ class DownloadedEmbeddedCoverResolver {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return null;
if (_pendingRefresh.remove(cleanPath)) {
_ensureCover(cleanPath, forceRefresh: true, onChanged: onChanged);
}
final cached = _cache[cleanPath];
if (cached != null) {
final now = DateTime.now().millisecondsSinceEpoch;
final lastPreviewCheck = _lastPreviewExistsCheckMillis[cleanPath] ?? 0;
final shouldVerifyExists =
now - lastPreviewCheck >= _minPreviewExistsCheckIntervalMs;
if (!shouldVerifyExists || File(cached.previewPath).existsSync()) {
if (shouldVerifyExists) {
_lastPreviewExistsCheckMillis[cleanPath] = now;
}
if (File(cached.previewPath).existsSync()) {
_touch(cleanPath, cached);
_scheduleModCheck(cleanPath, onChanged: onChanged);
return cached.previewPath;
}
_cache.remove(cleanPath);
_lastPreviewExistsCheckMillis.remove(cleanPath);
_cleanupTempCoverPathSync(cached.previewPath);
}
_ensureCover(cleanPath, onChanged: onChanged);
return null;
}
@@ -106,8 +94,9 @@ class DownloadedEmbeddedCoverResolver {
}
}
_pendingRefresh.add(cleanPath);
_failedExtract.remove(cleanPath);
_ensureCover(cleanPath, forceRefresh: true, onChanged: onChanged);
onChanged?.call();
}
static void invalidate(String? filePath) {
@@ -116,15 +105,30 @@ class DownloadedEmbeddedCoverResolver {
final cached = _cache.remove(cleanPath);
_pendingExtract.remove(cleanPath);
_pendingModCheck.remove(cleanPath);
_pendingRefresh.remove(cleanPath);
_failedExtract.remove(cleanPath);
_lastModCheckMillis.remove(cleanPath);
_lastPreviewExistsCheckMillis.remove(cleanPath);
if (cached != null) {
_cleanupTempCoverPathSync(cached.previewPath);
}
}
static void invalidatePathsNotIn(Set<String> validCleanPaths) {
if (validCleanPaths.isEmpty) {
final keys = _cache.keys.toList(growable: false);
for (final key in keys) {
invalidate(key);
}
return;
}
final staleKeys = _cache.keys
.where((path) => !validCleanPaths.contains(path))
.toList(growable: false);
for (final key in staleKeys) {
invalidate(key);
}
}
static void _touch(String cleanPath, _EmbeddedCoverCacheEntry entry) {
_cache
..remove(cleanPath)
@@ -139,44 +143,11 @@ class DownloadedEmbeddedCoverResolver {
_cleanupTempCoverPathSync(removed.previewPath);
}
_pendingExtract.remove(oldestKey);
_pendingModCheck.remove(oldestKey);
_pendingRefresh.remove(oldestKey);
_failedExtract.remove(oldestKey);
_lastModCheckMillis.remove(oldestKey);
_lastPreviewExistsCheckMillis.remove(oldestKey);
}
}
static void _scheduleModCheck(String cleanPath, {VoidCallback? onChanged}) {
if (_pendingModCheck.contains(cleanPath)) return;
final now = DateTime.now().millisecondsSinceEpoch;
final lastCheck = _lastModCheckMillis[cleanPath] ?? 0;
if (now - lastCheck < _minModCheckIntervalMs) return;
_lastModCheckMillis[cleanPath] = now;
_pendingModCheck.add(cleanPath);
Future.microtask(() async {
try {
final cached = _cache[cleanPath];
if (cached == null) return;
final currentModTime = await readFileModTimeMillis(cleanPath);
if (currentModTime != null &&
cached.sourceModTimeMillis != null &&
currentModTime != cached.sourceModTimeMillis) {
_ensureCover(
cleanPath,
forceRefresh: true,
knownModTime: currentModTime,
onChanged: onChanged,
);
}
} finally {
_pendingModCheck.remove(cleanPath);
}
});
}
static void _ensureCover(
String cleanPath, {
bool forceRefresh = false,
@@ -218,8 +189,6 @@ class DownloadedEmbeddedCoverResolver {
);
_touch(cleanPath, next);
_failedExtract.remove(cleanPath);
_lastPreviewExistsCheckMillis[cleanPath] =
DateTime.now().millisecondsSinceEpoch;
_trimCacheIfNeeded();
if (previous != null && previous.previewPath != outputPath) {