Files
SpotiFLAC-Mobile/lib/services/cover_cache_manager.dart
T

220 lines
6.4 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 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');
unawaited(_sweepOverByteCap(_cachePath!));
} catch (e) {
debugPrint('CoverCacheManager: Failed to initialize: $e');
}
}
/// 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';
}
}
}