feat: allow re-running audio quality analysis after cached result

The audio analysis card used to read from a persistent cache but offered no way to refresh the result when the underlying file had been re-downloaded at a different quality (for example, re-downloading a track as FLAC after capturing it as AAC). Add an explicit rescan control that clears the cached JSON + spectrogram, reruns the FFmpeg probe and analysis pipeline, and swaps in the fresh data while keeping the loading copy distinct from first-run analysis. A retry button is also exposed in the error card so transient failures do not require navigating away.

All audio_analysis strings now have a Re-analyze / Re-analyzing pair in the ARB catalog so every locale can translate them independently.
This commit is contained in:
zarzet
2026-05-10 21:27:54 +07:00
parent f84a33bbf2
commit b4031936a0
34 changed files with 333 additions and 11 deletions
+69 -11
View File
@@ -165,15 +165,24 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
}
}
Future<void> _analyze() async {
Future<void> _analyze({bool forceRefresh = false}) async {
if (_analyzing) return;
setState(() {
_analyzing = true;
_error = null;
if (forceRefresh) {
_spectrogramImage?.dispose();
_spectrogramImage = null;
_data = null;
}
});
try {
final cached = await _loadFromCache(widget.filePath);
if (forceRefresh) {
await _clearCache(widget.filePath);
}
final cached = forceRefresh ? null : await _loadFromCache(widget.filePath);
AudioAnalysisData data;
bool fromCache = false;
@@ -214,6 +223,21 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
}
}
static Future<void> _clearCache(String filePath) async {
try {
final dir = await _cacheDir();
final key = _cacheKey(filePath);
final jsonFile = File('${dir.path}/$key.json');
final imageFile = File('${dir.path}/$key.png');
if (await jsonFile.exists()) {
await jsonFile.delete();
}
if (await imageFile.exists()) {
await imageFile.delete();
}
} catch (_) {}
}
static String _cacheKey(String filePath) {
var hash = 0xcbf29ce484222325;
for (final byte in utf8.encode(filePath)) {
@@ -499,6 +523,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
if (_checkingCache) return const SizedBox.shrink();
if (_analyzing) {
final isRescan = _data != null || _spectrogramImage != null;
return Card(
color: cs.surfaceContainerLow,
child: Padding(
@@ -514,7 +539,9 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
),
const SizedBox(height: 12),
Text(
l10n.audioAnalysisAnalyzing,
isRescan
? l10n.audioAnalysisRescanning
: l10n.audioAnalysisAnalyzing,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
@@ -539,6 +566,18 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
style: TextStyle(color: cs.onErrorContainer, fontSize: 13),
),
),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: l10n.audioAnalysisRescan,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
color: cs.onErrorContainer,
onPressed: () => _analyze(forceRefresh: true),
),
],
),
),
@@ -592,7 +631,10 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AudioInfoCard(data: data),
_AudioInfoCard(
data: data,
onRescan: () => _analyze(forceRefresh: true),
),
if (_spectrogramImage != null) ...[
const SizedBox(height: 12),
_SpectrogramView(
@@ -796,8 +838,9 @@ Float64List _fft(Float64List realInput) {
class _AudioInfoCard extends StatelessWidget {
final AudioAnalysisData data;
final VoidCallback? onRescan;
const _AudioInfoCard({required this.data});
const _AudioInfoCard({required this.data, this.onRescan});
@override
Widget build(BuildContext context) {
@@ -815,14 +858,29 @@ class _AudioInfoCard extends StatelessWidget {
children: [
Icon(Icons.analytics_outlined, color: cs.primary, size: 20),
const SizedBox(width: 8),
Text(
context.l10n.audioAnalysisTitle,
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
Expanded(
child: Text(
context.l10n.audioAnalysisTitle,
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
if (onRescan != null)
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: context.l10n.audioAnalysisRescan,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
color: cs.onSurfaceVariant,
onPressed: onRescan,
),
],
),
const SizedBox(height: 12),