From b575a4ea38f50d6a4bd42443da568bfd43d94887 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:46:43 +0700 Subject: [PATCH] refactor(analysis): move the analysis disk cache into audio_analysis_cache.dart The card owned the bundle storage (key derivation, {key}.json, {key}.png and {key}_ch{n}.png, the shared trim budget) as static members, and the cache page spelled the audio_analysis_cache directory literal twice more. Move the storage verbatim into AudioAnalysisDiskCache next to the existing lease/trim policy. AudioAnalysisDiskCache.directoryIn is now the only place that names the directory; the card's cacheDir and both cache page call sites use it, with the page keeping its non-creating behavior and await order. loadFromCache takes the card's network input as a parameter instead of reading the field. The 63-bit FNV-1a cache key stays local: lib/utils/hash_utils.dart fnv1a32 uses a different basis, prime and width, so reusing it would rename every cached bundle on devices. --- .../settings/cache_management_page.dart | 5 +- lib/services/audio_analysis_cache.dart | 158 ++++++++++++++ lib/widgets/audio_analysis_widget.dart | 198 ++++-------------- 3 files changed, 203 insertions(+), 158 deletions(-) diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index c4d811e3..2b1fd3e5 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/audio_analysis_cache.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; import 'package:spotiflac_android/services/motion_artwork_store.dart'; @@ -96,7 +97,7 @@ class _CacheManagementPageState extends ConsumerState { Directory('${appSupportDir.path}/library_covers'), ); final audioAnalysisStatsFuture = _scanDirectory( - Directory('${appSupportDir.path}/audio_analysis_cache'), + AudioAnalysisDiskCache.directoryIn(appSupportDir.path), ); final prefs = await prefsFuture; @@ -216,7 +217,7 @@ class _CacheManagementPageState extends ConsumerState { Future _clearAudioAnalysisCache() async { final appSupportDir = await getApplicationSupportDirectory(); - final analysisDir = Directory('${appSupportDir.path}/audio_analysis_cache'); + final analysisDir = AudioAnalysisDiskCache.directoryIn(appSupportDir.path); await _clearDirectoryContents(analysisDir.path); } diff --git a/lib/services/audio_analysis_cache.dart b/lib/services/audio_analysis_cache.dart index 3846f7a2..0f124bda 100644 --- a/lib/services/audio_analysis_cache.dart +++ b/lib/services/audio_analysis_cache.dart @@ -1,4 +1,162 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/services/audio_analysis_models.dart'; +import 'package:spotiflac_android/services/network_audio_analysis_input.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +/// On-disk analysis bundles: `{key}.json`, `{key}.png` and `{key}_ch{n}.png` +/// in `audio_analysis_cache` under the application support directory. The +/// directory name, key derivation and file names are persisted on devices. +abstract final class AudioAnalysisDiskCache { + /// The one place that names the cache directory; it does not create it. + static Directory directoryIn(String appSupportPath) => + Directory('$appSupportPath/audio_analysis_cache'); + + static Future clearCache(String filePath) async { + try { + final dir = await cacheDir(); + final key = cacheKey(filePath); + final jsonFile = File('${dir.path}/$key.json'); + if (await jsonFile.exists()) { + await jsonFile.delete(); + } + await for (final entity in dir.list()) { + if (entity is! File) continue; + final name = entity.path.replaceAll('\\', '/').split('/').last; + final isCombined = name == '$key.png'; + final isChannel = name.startsWith('${key}_ch') && name.endsWith('.png'); + if (isCombined || isChannel) { + await entity.delete(); + } + } + } catch (_) {} + } + + static String cacheKey(String filePath) { + var hash = 0xcbf29ce484222325; + for (final byte in utf8.encode(filePath)) { + hash ^= byte; + hash = (hash * 0x100000001b3) & 0x7FFFFFFFFFFFFFFF; + } + return hash.toRadixString(16); + } + + static Future cacheDir() async { + final appSupport = await getApplicationSupportDirectory(); + final dir = directoryIn(appSupport.path); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + static final cacheBudget = AudioAnalysisCache(); + + static Future trimCache() async { + try { + await cacheBudget.trim(await cacheDir()); + } catch (_) { + // Cache maintenance must not interrupt the selected track's analysis. + } + } + + static Future loadFromCache( + String filePath, + NetworkAudioAnalysisInput networkInput, + ) async { + try { + final dir = await cacheDir(); + final key = cacheKey(filePath); + final file = File('${dir.path}/$key.json'); + if (!await file.exists()) return null; + + final json = Map.from( + jsonDecode(await file.readAsString()) as Map, + ); + if (json['cacheVersion'] != AudioAnalysisData.cacheVersion) { + return null; + } + final cachedSize = json['fileSize'] as int; + + if (NetworkAudioAnalysisInput.isNetwork(filePath)) { + final currentSize = await networkInput.length(filePath); + if (currentSize <= 0 || currentSize != cachedSize) return null; + } else if (!filePath.startsWith('content://')) { + final currentSize = await File(filePath).length(); + if (currentSize != cachedSize) return null; + } else { + final stat = await PlatformBridge.safStat(filePath); + final currentSize = (stat['size'] as num?)?.toInt() ?? 0; + if (currentSize > 0 && currentSize != cachedSize) return null; + } + + final data = AudioAnalysisData.fromJson(json); + await file.setLastModified(DateTime.now()); + return data; + } catch (_) { + return null; + } + } + + static Future saveToCache( + String filePath, + AudioAnalysisData data, + ) async { + try { + final dir = await cacheDir(); + final key = cacheKey(filePath); + final file = File('${dir.path}/$key.json'); + await file.writeAsString(jsonEncode(data.toJson())); + } catch (_) {} + } + + static Future saveSpectrogramToCache( + String filePath, + ui.Image image, { + required int channel, + }) async { + try { + final dir = await cacheDir(); + final key = cacheKey(filePath); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData != null) { + final file = File( + '${dir.path}/${_spectrogramCacheFileName(key, channel)}', + ); + await file.writeAsBytes(byteData.buffer.asUint8List()); + await cacheBudget.trim(dir); + } + } catch (_) {} + } + + static String _spectrogramCacheFileName(String key, int channel) => + channel < 0 ? '$key.png' : '${key}_ch$channel.png'; + + static Future loadSpectrogramFromCache( + String filePath, { + required int channel, + }) async { + try { + final dir = await cacheDir(); + final key = cacheKey(filePath); + final file = File( + '${dir.path}/${_spectrogramCacheFileName(key, channel)}', + ); + if (!await file.exists()) return null; + + final bytes = await file.readAsBytes(); + final completer = Completer(); + ui.decodeImageFromList(bytes, completer.complete); + return await completer.future; + } catch (_) { + return null; + } + } +} /// Evicts complete analysis bundles (metadata, combined image and channels). /// Leases protect cache reads/writes while a card changes tracks or channels. diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 9271156f..cc0fdb61 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; @@ -172,18 +171,23 @@ class _AudioAnalysisCardState extends State { Future _tryLoadFromCache() async { final expectedPath = widget.filePath; final requestId = _spectrogramRequestId; - final releaseCache = _cacheBudget.retain(_cacheKey(expectedPath)); + final releaseCache = AudioAnalysisDiskCache.cacheBudget.retain( + AudioAnalysisDiskCache.cacheKey(expectedPath), + ); bool isCurrentRequest() => mounted && widget.filePath == expectedPath && requestId == _spectrogramRequestId; try { - final cached = await _loadFromCache(expectedPath); + final cached = await AudioAnalysisDiskCache.loadFromCache( + expectedPath, + _networkInput, + ); if (cached != null && isCurrentRequest()) { final unsupported = unsupportedAudioAnalysisCodecLabel(cached.codec); if (unsupported != null) { - await _clearCache(expectedPath); + await AudioAnalysisDiskCache.clearCache(expectedPath); if (isCurrentRequest()) { setState(() { _unsupportedCodec = unsupported; @@ -196,7 +200,7 @@ class _AudioAnalysisCardState extends State { _data = cached; _checkingCache = false; }); - var image = await _loadSpectrogramFromCache( + var image = await AudioAnalysisDiskCache.loadSpectrogramFromCache( expectedPath, channel: _spectrogramChannel, ); @@ -223,7 +227,7 @@ class _AudioAnalysisCardState extends State { // Missing or unreadable cache entries fall back to the Analyze action. } finally { releaseCache(); - unawaited(_trimCache()); + unawaited(AudioAnalysisDiskCache.trimCache()); } if (isCurrentRequest()) { setState(() => _checkingCache = false); @@ -247,14 +251,20 @@ class _AudioAnalysisCardState extends State { sampleRate: data?.sampleRate, channels: data?.channels, ); - await _saveSpectrogramToCache(sourcePath, artifact.image, channel: channel); + await AudioAnalysisDiskCache.saveSpectrogramToCache( + sourcePath, + artifact.image, + channel: channel, + ); return artifact.image; } Future _analyze({bool forceRefresh = false}) async { if (_analyzing) return; final sourcePath = widget.filePath; - final releaseCache = _cacheBudget.retain(_cacheKey(sourcePath)); + final releaseCache = AudioAnalysisDiskCache.cacheBudget.retain( + AudioAnalysisDiskCache.cacheKey(sourcePath), + ); final requestId = ++_spectrogramRequestId; _analysisJobs.invalidate(); _networkInput.cancel(); @@ -273,10 +283,15 @@ class _AudioAnalysisCardState extends State { try { if (forceRefresh) { - await _clearCache(sourcePath); + await AudioAnalysisDiskCache.clearCache(sourcePath); } - final cached = forceRefresh ? null : await _loadFromCache(sourcePath); + final cached = forceRefresh + ? null + : await AudioAnalysisDiskCache.loadFromCache( + sourcePath, + _networkInput, + ); _checkAnalysisRequest(requestId); AudioAnalysisData data; ui.Image? image; @@ -287,7 +302,7 @@ class _AudioAnalysisCardState extends State { throw UnsupportedAudioAnalysisCodecException(unsupported); } data = cached; - image = await _loadSpectrogramFromCache( + image = await AudioAnalysisDiskCache.loadSpectrogramFromCache( sourcePath, channel: _spectrogramChannel, ); @@ -295,8 +310,8 @@ class _AudioAnalysisCardState extends State { final result = await _runAnalysis(sourcePath, requestId); data = result.data; image = result.spectrogramImage; - await _saveToCache(sourcePath, data); - await _saveSpectrogramToCache( + await AudioAnalysisDiskCache.saveToCache(sourcePath, data); + await AudioAnalysisDiskCache.saveSpectrogramToCache( sourcePath, image, channel: _spectrogramChannel, @@ -338,145 +353,7 @@ class _AudioAnalysisCardState extends State { } } finally { releaseCache(); - unawaited(_trimCache()); - } - } - - static Future _clearCache(String filePath) async { - try { - final dir = await _cacheDir(); - final key = _cacheKey(filePath); - final jsonFile = File('${dir.path}/$key.json'); - if (await jsonFile.exists()) { - await jsonFile.delete(); - } - await for (final entity in dir.list()) { - if (entity is! File) continue; - final name = entity.path.replaceAll('\\', '/').split('/').last; - final isCombined = name == '$key.png'; - final isChannel = name.startsWith('${key}_ch') && name.endsWith('.png'); - if (isCombined || isChannel) { - await entity.delete(); - } - } - } catch (_) {} - } - - static String _cacheKey(String filePath) { - var hash = 0xcbf29ce484222325; - for (final byte in utf8.encode(filePath)) { - hash ^= byte; - hash = (hash * 0x100000001b3) & 0x7FFFFFFFFFFFFFFF; - } - return hash.toRadixString(16); - } - - static Future _cacheDir() async { - final appSupport = await getApplicationSupportDirectory(); - final dir = Directory('${appSupport.path}/audio_analysis_cache'); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dir; - } - - static final _cacheBudget = AudioAnalysisCache(); - - static Future _trimCache() async { - try { - await _cacheBudget.trim(await _cacheDir()); - } catch (_) { - // Cache maintenance must not interrupt the selected track's analysis. - } - } - - Future _loadFromCache(String filePath) async { - try { - final dir = await _cacheDir(); - final key = _cacheKey(filePath); - final file = File('${dir.path}/$key.json'); - if (!await file.exists()) return null; - - final json = Map.from( - jsonDecode(await file.readAsString()) as Map, - ); - if (json['cacheVersion'] != AudioAnalysisData.cacheVersion) { - return null; - } - final cachedSize = json['fileSize'] as int; - - if (NetworkAudioAnalysisInput.isNetwork(filePath)) { - final currentSize = await _networkInput.length(filePath); - if (currentSize <= 0 || currentSize != cachedSize) return null; - } else if (!filePath.startsWith('content://')) { - final currentSize = await File(filePath).length(); - if (currentSize != cachedSize) return null; - } else { - final stat = await PlatformBridge.safStat(filePath); - final currentSize = (stat['size'] as num?)?.toInt() ?? 0; - if (currentSize > 0 && currentSize != cachedSize) return null; - } - - final data = AudioAnalysisData.fromJson(json); - await file.setLastModified(DateTime.now()); - return data; - } catch (_) { - return null; - } - } - - static Future _saveToCache( - String filePath, - AudioAnalysisData data, - ) async { - try { - final dir = await _cacheDir(); - final key = _cacheKey(filePath); - final file = File('${dir.path}/$key.json'); - await file.writeAsString(jsonEncode(data.toJson())); - } catch (_) {} - } - - static Future _saveSpectrogramToCache( - String filePath, - ui.Image image, { - required int channel, - }) async { - try { - final dir = await _cacheDir(); - final key = _cacheKey(filePath); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - if (byteData != null) { - final file = File( - '${dir.path}/${_spectrogramCacheFileName(key, channel)}', - ); - await file.writeAsBytes(byteData.buffer.asUint8List()); - await _cacheBudget.trim(dir); - } - } catch (_) {} - } - - static String _spectrogramCacheFileName(String key, int channel) => - channel < 0 ? '$key.png' : '${key}_ch$channel.png'; - - static Future _loadSpectrogramFromCache( - String filePath, { - required int channel, - }) async { - try { - final dir = await _cacheDir(); - final key = _cacheKey(filePath); - final file = File( - '${dir.path}/${_spectrogramCacheFileName(key, channel)}', - ); - if (!await file.exists()) return null; - - final bytes = await file.readAsBytes(); - final completer = Completer(); - ui.decodeImageFromList(bytes, completer.complete); - return await completer.future; - } catch (_) { - return null; + unawaited(AudioAnalysisDiskCache.trimCache()); } } @@ -753,7 +630,9 @@ class _AudioAnalysisCardState extends State { final previousChannel = _spectrogramChannel; final sourcePath = widget.filePath; - final releaseCache = _cacheBudget.retain(_cacheKey(sourcePath)); + final releaseCache = AudioAnalysisDiskCache.cacheBudget.retain( + AudioAnalysisDiskCache.cacheKey(sourcePath), + ); final requestId = ++_spectrogramRequestId; setState(() { _spectrogramChannel = channel; @@ -762,7 +641,10 @@ class _AudioAnalysisCardState extends State { ui.Image? image; try { - image = await _loadSpectrogramFromCache(sourcePath, channel: channel); + image = await AudioAnalysisDiskCache.loadSpectrogramFromCache( + sourcePath, + channel: channel, + ); if (image == null) { final artifact = await _generateSpectrogramForFile( sourcePath, @@ -773,7 +655,11 @@ class _AudioAnalysisCardState extends State { channels: data.channels, ); image = artifact.image; - await _saveSpectrogramToCache(sourcePath, image, channel: channel); + await AudioAnalysisDiskCache.saveSpectrogramToCache( + sourcePath, + image, + channel: channel, + ); } if (!mounted || requestId != _spectrogramRequestId) { @@ -795,7 +681,7 @@ class _AudioAnalysisCardState extends State { } } finally { releaseCache(); - unawaited(_trimCache()); + unawaited(AudioAnalysisDiskCache.trimCache()); } }