mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 12:48:03 +02:00
b50eec5a47
- Embed DownloadQueueLookup into DownloadQueueState; add updatedForIndices() for O(changed) incremental updates during frequent progress ticks instead of full O(n) rebuild - downloadQueueLookupProvider now reads pre-computed lookup from state directly - Replace sync file deletion in DownloadedEmbeddedCoverResolver with unawaited async cleanup to avoid blocking the main thread - Parse JSON payloads on iOS native side (parseJsonPayload) so event sinks and method channel responses return native objects, avoiding redundant Dart-side JSON decode - Use .cast<String, dynamic>() instead of Map.from() in _decodeMapResult for zero-copy map handling
250 lines
7.2 KiB
Dart
250 lines
7.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
|
|
class _EmbeddedCoverCacheEntry {
|
|
final String previewPath;
|
|
final int? sourceModTimeMillis;
|
|
|
|
const _EmbeddedCoverCacheEntry({
|
|
required this.previewPath,
|
|
this.sourceModTimeMillis,
|
|
});
|
|
}
|
|
|
|
/// Shared resolver for embedded cover previews from downloaded/local files.
|
|
/// It keeps a bounded in-memory cache and only refreshes extraction
|
|
/// when the source file changed.
|
|
class DownloadedEmbeddedCoverResolver {
|
|
static const int _maxCacheEntries = 180;
|
|
|
|
static final LinkedHashMap<String, _EmbeddedCoverCacheEntry> _cache =
|
|
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
|
|
static final Set<String> _pendingExtract = <String>{};
|
|
static final Set<String> _pendingRefresh = <String>{};
|
|
static final Set<String> _pendingPreviewValidation = <String>{};
|
|
static final Set<String> _failedExtract = <String>{};
|
|
|
|
static String cleanFilePath(String? filePath) {
|
|
if (filePath == null) return '';
|
|
if (filePath.startsWith('EXISTS:')) {
|
|
return filePath.substring(7);
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
static Future<int?> readFileModTimeMillis(String? filePath) async {
|
|
final cleanPath = cleanFilePath(filePath);
|
|
if (cleanPath.isEmpty) return null;
|
|
|
|
if (isContentUri(cleanPath)) {
|
|
try {
|
|
final modTimes = await PlatformBridge.getSafFileModTimes([cleanPath]);
|
|
return modTimes[cleanPath];
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
final stat = await File(cleanPath).stat();
|
|
return stat.modified.millisecondsSinceEpoch;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static String? resolve(String? filePath, {VoidCallback? onChanged}) {
|
|
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) {
|
|
_touch(cleanPath, cached);
|
|
_validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged);
|
|
return cached.previewPath;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static Future<void> scheduleRefreshForPath(
|
|
String? filePath, {
|
|
int? beforeModTime,
|
|
bool force = false,
|
|
VoidCallback? onChanged,
|
|
}) 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;
|
|
}
|
|
}
|
|
|
|
_pendingRefresh.add(cleanPath);
|
|
_failedExtract.remove(cleanPath);
|
|
onChanged?.call();
|
|
}
|
|
|
|
static void invalidate(String? filePath) {
|
|
final cleanPath = cleanFilePath(filePath);
|
|
if (cleanPath.isEmpty) return;
|
|
|
|
final cached = _cache.remove(cleanPath);
|
|
_pendingExtract.remove(cleanPath);
|
|
_pendingRefresh.remove(cleanPath);
|
|
_pendingPreviewValidation.remove(cleanPath);
|
|
_failedExtract.remove(cleanPath);
|
|
if (cached != null) {
|
|
_scheduleTempCoverCleanup(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)
|
|
..[cleanPath] = entry;
|
|
}
|
|
|
|
static void _trimCacheIfNeeded() {
|
|
while (_cache.length > _maxCacheEntries) {
|
|
final oldestKey = _cache.keys.first;
|
|
final removed = _cache.remove(oldestKey);
|
|
if (removed != null) {
|
|
_scheduleTempCoverCleanup(removed.previewPath);
|
|
}
|
|
_pendingExtract.remove(oldestKey);
|
|
_pendingRefresh.remove(oldestKey);
|
|
_pendingPreviewValidation.remove(oldestKey);
|
|
_failedExtract.remove(oldestKey);
|
|
}
|
|
}
|
|
|
|
static void _validateCachedPreviewAsync(
|
|
String cleanPath,
|
|
_EmbeddedCoverCacheEntry entry, {
|
|
VoidCallback? onChanged,
|
|
}) {
|
|
if (_pendingPreviewValidation.contains(cleanPath)) return;
|
|
_pendingPreviewValidation.add(cleanPath);
|
|
Future.microtask(() async {
|
|
try {
|
|
final exists = await fileExists(entry.previewPath);
|
|
if (!exists) {
|
|
final latest = _cache[cleanPath];
|
|
if (latest != null && latest.previewPath == entry.previewPath) {
|
|
_cache.remove(cleanPath);
|
|
_failedExtract.remove(cleanPath);
|
|
onChanged?.call();
|
|
}
|
|
_scheduleTempCoverCleanup(entry.previewPath);
|
|
}
|
|
} finally {
|
|
_pendingPreviewValidation.remove(cleanPath);
|
|
}
|
|
});
|
|
}
|
|
|
|
static void _ensureCover(
|
|
String cleanPath, {
|
|
bool forceRefresh = false,
|
|
int? knownModTime,
|
|
VoidCallback? onChanged,
|
|
}) {
|
|
if (cleanPath.isEmpty) return;
|
|
if (_pendingExtract.contains(cleanPath)) return;
|
|
if (!forceRefresh && _cache.containsKey(cleanPath)) return;
|
|
if (!forceRefresh && _failedExtract.contains(cleanPath)) return;
|
|
|
|
_pendingExtract.add(cleanPath);
|
|
Future.microtask(() async {
|
|
String? outputPath;
|
|
try {
|
|
final modTime = knownModTime ?? await readFileModTimeMillis(cleanPath);
|
|
final tempDir = await Directory.systemTemp.createTemp(
|
|
'download_cover_preview_',
|
|
);
|
|
outputPath =
|
|
'${tempDir.path}${Platform.pathSeparator}cover_preview.jpg';
|
|
final result = await PlatformBridge.extractCoverToFile(
|
|
cleanPath,
|
|
outputPath,
|
|
);
|
|
|
|
final hasCover =
|
|
result['error'] == null && await File(outputPath).exists();
|
|
if (!hasCover) {
|
|
_failedExtract.add(cleanPath);
|
|
_scheduleTempCoverCleanup(outputPath);
|
|
return;
|
|
}
|
|
|
|
final previous = _cache[cleanPath];
|
|
final next = _EmbeddedCoverCacheEntry(
|
|
previewPath: outputPath,
|
|
sourceModTimeMillis: modTime,
|
|
);
|
|
_touch(cleanPath, next);
|
|
_failedExtract.remove(cleanPath);
|
|
_trimCacheIfNeeded();
|
|
|
|
if (previous != null && previous.previewPath != outputPath) {
|
|
_scheduleTempCoverCleanup(previous.previewPath);
|
|
}
|
|
onChanged?.call();
|
|
} catch (_) {
|
|
_failedExtract.add(cleanPath);
|
|
_scheduleTempCoverCleanup(outputPath);
|
|
} finally {
|
|
_pendingExtract.remove(cleanPath);
|
|
}
|
|
});
|
|
}
|
|
|
|
static void _scheduleTempCoverCleanup(String? coverPath) {
|
|
unawaited(_cleanupTempCoverPath(coverPath));
|
|
}
|
|
|
|
static Future<void> _cleanupTempCoverPath(String? coverPath) async {
|
|
if (coverPath == null || coverPath.isEmpty) return;
|
|
try {
|
|
final file = File(coverPath);
|
|
try {
|
|
await file.delete();
|
|
} catch (_) {}
|
|
try {
|
|
await file.parent.delete(recursive: true);
|
|
} catch (_) {}
|
|
} catch (_) {}
|
|
}
|
|
}
|