mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 07:18:49 +02:00
232 lines
6.8 KiB
Dart
232 lines
6.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/painting.dart';
|
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
/// Persistent cache manager for album/track cover images.
|
|
///
|
|
/// Unlike the default cache manager which stores in temp directory
|
|
/// (can be cleared by system anytime), this stores in app support
|
|
/// directory which persists across app restarts.
|
|
class CoverCacheManager {
|
|
static const String _cacheKey = 'coverImageCache';
|
|
static const int _maxCacheObjects = 1000;
|
|
static const Duration _maxCacheAge = Duration(days: 365);
|
|
// flutter_cache_manager only caps object count, not bytes; hi-res covers
|
|
// run 300KB-1.5MB each, so 1000 objects can mean hundreds of MB on a
|
|
// storage-starved device. Sweep oldest files past the byte cap on init.
|
|
static const int _maxCacheBytes = 150 << 20;
|
|
static const int _sweepTargetBytes = 120 << 20;
|
|
|
|
static CacheManager? _instance;
|
|
static bool _initialized = false;
|
|
static bool _maintenanceScheduled = false;
|
|
static String? _cachePath;
|
|
|
|
static CacheManager get instance {
|
|
if (!_initialized || _instance == null) {
|
|
debugPrint(
|
|
'CoverCacheManager: Not initialized, using DefaultCacheManager',
|
|
);
|
|
return DefaultCacheManager();
|
|
}
|
|
return _instance!;
|
|
}
|
|
|
|
static bool get isInitialized => _initialized && _instance != null;
|
|
|
|
static Future<void> initialize() async {
|
|
if (_initialized) return;
|
|
|
|
try {
|
|
final appDir = await getApplicationSupportDirectory();
|
|
_cachePath = p.join(appDir.path, 'cover_cache');
|
|
|
|
await Directory(_cachePath!).create(recursive: true);
|
|
|
|
debugPrint('CoverCacheManager: Initializing at $_cachePath');
|
|
|
|
_instance = _createManager(_cachePath!);
|
|
|
|
_initialized = true;
|
|
debugPrint('CoverCacheManager: Initialized successfully');
|
|
} catch (e) {
|
|
debugPrint('CoverCacheManager: Failed to initialize: $e');
|
|
}
|
|
}
|
|
|
|
/// Runs byte-cap maintenance after startup work has settled. Calling this
|
|
/// repeatedly is cheap; only the first call schedules a sweep.
|
|
static void scheduleMaintenance({
|
|
Duration delay = const Duration(seconds: 20),
|
|
}) {
|
|
if (_maintenanceScheduled) return;
|
|
_maintenanceScheduled = true;
|
|
unawaited(
|
|
Future<void>.delayed(delay).then((_) async {
|
|
final cachePath = _cachePath;
|
|
if (cachePath != null) await _sweepOverByteCap(cachePath);
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Deletes oldest cover files until the cache is back under
|
|
/// [_sweepTargetBytes]. Stale JSON repo entries self-heal: a missing file
|
|
/// is a cache miss and gets re-downloaded on demand.
|
|
static Future<void> _sweepOverByteCap(String cachePath) async {
|
|
try {
|
|
final dir = Directory(cachePath);
|
|
if (!await dir.exists()) return;
|
|
|
|
final files = <File>[];
|
|
var totalSize = 0;
|
|
await for (final entity in dir.list(recursive: true)) {
|
|
if (entity is File && !entity.path.endsWith('.json')) {
|
|
files.add(entity);
|
|
totalSize += await entity.length();
|
|
}
|
|
}
|
|
if (totalSize <= _maxCacheBytes) return;
|
|
|
|
final stats = <File, FileStat>{
|
|
for (final file in files) file: await file.stat(),
|
|
};
|
|
files.sort((a, b) => stats[a]!.modified.compareTo(stats[b]!.modified));
|
|
for (final file in files) {
|
|
if (totalSize <= _sweepTargetBytes) break;
|
|
try {
|
|
totalSize -= stats[file]!.size;
|
|
await file.delete();
|
|
} catch (_) {}
|
|
}
|
|
debugPrint('CoverCacheManager: Swept cover cache over byte cap');
|
|
} catch (e) {
|
|
debugPrint('CoverCacheManager: Byte-cap sweep failed: $e');
|
|
}
|
|
}
|
|
|
|
static Future<void> clearCache() async {
|
|
if (!_initialized || _instance == null || _cachePath == null) {
|
|
await initialize();
|
|
}
|
|
|
|
final instance = _instance;
|
|
final cachePath = _cachePath;
|
|
|
|
if (instance == null || cachePath == null) return;
|
|
|
|
try {
|
|
await instance.emptyCache();
|
|
} catch (e) {
|
|
debugPrint('CoverCacheManager: emptyCache failed, fallback to wipe: $e');
|
|
}
|
|
|
|
await _wipeDirectory(cachePath);
|
|
|
|
final imageCache = PaintingBinding.instance.imageCache;
|
|
imageCache.clear();
|
|
imageCache.clearLiveImages();
|
|
|
|
instance.store.emptyMemoryCache();
|
|
_instance = _createManager(cachePath);
|
|
_initialized = true;
|
|
}
|
|
|
|
static Future<CacheStats> getStats() async {
|
|
if (_cachePath == null) {
|
|
try {
|
|
final appDir = await getApplicationSupportDirectory();
|
|
_cachePath = p.join(appDir.path, 'cover_cache');
|
|
} catch (_) {
|
|
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
|
|
}
|
|
}
|
|
|
|
if (_cachePath == null) {
|
|
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
|
|
}
|
|
|
|
final cacheDir = Directory(_cachePath!);
|
|
|
|
if (!await cacheDir.exists()) {
|
|
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
|
|
}
|
|
|
|
int fileCount = 0;
|
|
int totalSize = 0;
|
|
|
|
try {
|
|
await for (final entity in cacheDir.list(recursive: true)) {
|
|
if (entity is File) {
|
|
fileCount++;
|
|
totalSize += await entity.length();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('CoverCacheManager: Error getting stats: $e');
|
|
}
|
|
|
|
return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
|
}
|
|
|
|
static CacheManager _createManager(String cachePath) {
|
|
return CacheManager(
|
|
Config(
|
|
_cacheKey,
|
|
stalePeriod: _maxCacheAge,
|
|
maxNrOfCacheObjects: _maxCacheObjects,
|
|
repo: JsonCacheInfoRepository(path: cachePath),
|
|
fileSystem: IOFileSystem(cachePath),
|
|
fileService: HttpFileService(),
|
|
),
|
|
);
|
|
}
|
|
|
|
static Future<void> _wipeDirectory(String path) async {
|
|
final directory = Directory(path);
|
|
if (!await directory.exists()) {
|
|
await directory.create(recursive: true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final entities = <FileSystemEntity>[];
|
|
await for (final entity in directory.list(followLinks: false)) {
|
|
entities.add(entity);
|
|
}
|
|
|
|
for (final entity in entities) {
|
|
try {
|
|
await entity.delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
|
|
try {
|
|
await directory.create(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
class CacheStats {
|
|
final int fileCount;
|
|
final int totalSizeBytes;
|
|
|
|
const CacheStats({required this.fileCount, required this.totalSizeBytes});
|
|
|
|
String get formattedSize {
|
|
if (totalSizeBytes < 1024) {
|
|
return '$totalSizeBytes B';
|
|
} else if (totalSizeBytes < 1024 * 1024) {
|
|
return '${(totalSizeBytes / 1024).toStringAsFixed(1)} KB';
|
|
} else if (totalSizeBytes < 1024 * 1024 * 1024) {
|
|
return '${(totalSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
} else {
|
|
return '${(totalSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
|
}
|
|
}
|
|
}
|