diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index 6efcf648..f0a51672 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -61,7 +61,10 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { if (_fileExists) ...[ const SizedBox(height: 16), - AudioAnalysisCard(filePath: _filePath), + AudioAnalysisCard( + filePath: _filePath, + codecHint: _storedAudioFormat, + ), ], const SizedBox(height: 24), diff --git a/lib/widgets/audio_analysis_models.dart b/lib/widgets/audio_analysis_models.dart index 5270c98a..b4e6645b 100644 --- a/lib/widgets/audio_analysis_models.dart +++ b/lib/widgets/audio_analysis_models.dart @@ -191,6 +191,7 @@ double? _estimateEffectiveSpectralCutoffInIsolate( class _MediaInfo { final int fileSize; + final String codecName; final String codec; final String container; final String decodedSampleFormat; @@ -204,6 +205,7 @@ class _MediaInfo { const _MediaInfo({ required this.fileSize, + required this.codecName, required this.codec, required this.container, required this.decodedSampleFormat, diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 69eb015d..8d95084e 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -37,6 +37,29 @@ String formatAudioAnalysisSpectralCutoff( return '${cutoffHz.round()} Hz'; } +final RegExp _ac4CodecTokenPattern = RegExp( + r'(^|[^a-z0-9])ac[\s_-]?4($|[^a-z0-9])', + caseSensitive: false, +); + +/// Returns whether the bundled FFmpeg decoder can analyze this audio codec. +/// Container formats such as MP4 are intentionally not used here because the +/// same container may hold supported AAC/ALAC/E-AC-3 or unsupported AC-4. +bool isAudioAnalysisCodecSupported(String? codecName) { + final value = codecName?.trim() ?? ''; + if (value.isEmpty) return true; + return !_ac4CodecTokenPattern.hasMatch(value); +} + +String? unsupportedAudioAnalysisCodecLabel(String? codecName) => + isAudioAnalysisCodecSupported(codecName) ? null : 'AC-4'; + +class _UnsupportedAudioAnalysisCodecException implements Exception { + final String codecLabel; + + const _UnsupportedAudioAnalysisCodecException(this.codecLabel); +} + String _buildShowspectrumOptions({required int width, required String color}) { return 'showspectrumpic=' 's=${width}x$audioSpectrogramHeight:' @@ -442,8 +465,9 @@ double _spectralPercentile( class AudioAnalysisCard extends StatefulWidget { final String filePath; + final String? codecHint; - const AudioAnalysisCard({super.key, required this.filePath}); + const AudioAnalysisCard({super.key, required this.filePath, this.codecHint}); @override State createState() => _AudioAnalysisCardState(); @@ -458,6 +482,7 @@ class _AudioAnalysisCardState extends State { int _spectrogramChannel = -1; bool _spectrogramChannelLoading = false; int _spectrogramRequestId = 0; + String? _unsupportedCodec; static const _supportedExtensions = { '.flac', @@ -487,8 +512,37 @@ class _AudioAnalysisCardState extends State { @override void initState() { super.initState(); + _unsupportedCodec = unsupportedAudioAnalysisCodecLabel(widget.codecHint); if (_isSupported) { - _tryLoadFromCache(); + if (_unsupportedCodec == null) { + _tryLoadFromCache(); + } else { + _checkingCache = false; + } + } + } + + @override + void didUpdateWidget(covariant AudioAnalysisCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filePath == widget.filePath && + oldWidget.codecHint == widget.codecHint) { + return; + } + + _spectrogramRequestId++; + _spectrogramImage?.dispose(); + _spectrogramImage = null; + _spectrogramChannel = -1; + _spectrogramChannelLoading = false; + _data = null; + _error = null; + _analyzing = false; + _unsupportedCodec = unsupportedAudioAnalysisCodecLabel(widget.codecHint); + _checkingCache = _isSupported && _unsupportedCodec == null; + + if (_checkingCache) { + unawaited(_tryLoadFromCache()); } } @@ -500,19 +554,37 @@ class _AudioAnalysisCardState extends State { } Future _tryLoadFromCache() async { + final expectedPath = widget.filePath; + final requestId = _spectrogramRequestId; + bool isCurrentRequest() => + mounted && + widget.filePath == expectedPath && + requestId == _spectrogramRequestId; + try { - final cached = await _loadFromCache(widget.filePath); - if (cached != null && mounted) { + final cached = await _loadFromCache(expectedPath); + if (cached != null && isCurrentRequest()) { + final unsupported = unsupportedAudioAnalysisCodecLabel(cached.codec); + if (unsupported != null) { + await _clearCache(expectedPath); + if (isCurrentRequest()) { + setState(() { + _unsupportedCodec = unsupported; + _checkingCache = false; + }); + } + return; + } setState(() { _data = cached; _checkingCache = false; }); var image = await _loadSpectrogramFromCache( - widget.filePath, + expectedPath, channel: _spectrogramChannel, ); - image ??= await _generateAndCacheSpectrogram(); - if (mounted) { + image ??= await _generateAndCacheSpectrogram(filePath: expectedPath); + if (isCurrentRequest()) { setState(() { _spectrogramImage?.dispose(); _spectrogramImage = image; @@ -523,18 +595,19 @@ class _AudioAnalysisCardState extends State { return; } } catch (_) {} - if (mounted) { + if (isCurrentRequest()) { setState(() => _checkingCache = false); } } - Future _generateAndCacheSpectrogram() async { + Future _generateAndCacheSpectrogram({String? filePath}) async { + final sourcePath = filePath ?? widget.filePath; final artifact = await _generateSpectrogramForFile( - widget.filePath, + sourcePath, channel: _spectrogramChannel, ); await _saveSpectrogramToCache( - widget.filePath, + sourcePath, artifact.image, channel: _spectrogramChannel, ); @@ -568,6 +641,10 @@ class _AudioAnalysisCardState extends State { ui.Image? image; if (cached != null) { + final unsupported = unsupportedAudioAnalysisCodecLabel(cached.codec); + if (unsupported != null) { + throw _UnsupportedAudioAnalysisCodecException(unsupported); + } data = cached; image = await _loadSpectrogramFromCache( widget.filePath, @@ -597,6 +674,14 @@ class _AudioAnalysisCardState extends State { } else { image.dispose(); } + } on _UnsupportedAudioAnalysisCodecException catch (e) { + if (mounted) { + setState(() { + _unsupportedCodec = e.codecLabel; + _error = null; + _analyzing = false; + }); + } } catch (e) { if (mounted) { setState(() { @@ -742,6 +827,10 @@ class _AudioAnalysisCardState extends State { try { final info = await _getMediaInfo(workingPath); + final unsupported = unsupportedAudioAnalysisCodecLabel(info.codecName); + if (unsupported != null) { + throw _UnsupportedAudioAnalysisCodecException(unsupported); + } _GeneratedSpectrogram? spectrogram; try { spectrogram = await _generateSpectrogram( @@ -1047,6 +1136,7 @@ class _AudioAnalysisCardState extends State { return _MediaInfo( fileSize: fileSize, + codecName: codecName, codec: _formatCodecLabel(codecName, codecLongName), container: _formatContainerLabel(formatName, formatLongName), decodedSampleFormat: decodedSampleFormat, @@ -1243,6 +1333,51 @@ class _AudioAnalysisCardState extends State { if (_checkingCache) return const SizedBox.shrink(); + if (_unsupportedCodec != null) { + return Card( + elevation: 0, + color: settingsGroupColor(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: cs.primary, size: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.audioAnalysisTitle, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + const SizedBox(height: 4), + Text( + '${l10n.snackbarUnsupportedAudioFormat}: ' + '$_unsupportedCodec', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + if (_analyzing) { final isRescan = _data != null || _spectrogramImage != null; return Card( diff --git a/test/audio_analysis_spectrogram_test.dart b/test/audio_analysis_spectrogram_test.dart index 3dcbe7ab..bbde1b7c 100644 --- a/test/audio_analysis_spectrogram_test.dart +++ b/test/audio_analysis_spectrogram_test.dart @@ -4,6 +4,24 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; void main() { + group('audio analysis codec support', () { + test('rejects AC-4 aliases unsupported by the bundled decoder', () { + expect(isAudioAnalysisCodecSupported('ac4'), isFalse); + expect(isAudioAnalysisCodecSupported('AC-4'), isFalse); + expect(isAudioAnalysisCodecSupported('Dolby Atmos (AC_4)'), isFalse); + expect(unsupportedAudioAnalysisCodecLabel('AC-4'), 'AC-4'); + }); + + test('keeps supported codecs inside MP4 analyzable', () { + expect(isAudioAnalysisCodecSupported('aac'), isTrue); + expect(isAudioAnalysisCodecSupported('alac'), isTrue); + expect(isAudioAnalysisCodecSupported('eac3'), isTrue); + expect(isAudioAnalysisCodecSupported('E-AC-3'), isTrue); + expect(isAudioAnalysisCodecSupported('flac'), isTrue); + expect(isAudioAnalysisCodecSupported(null), isTrue); + }); + }); + group('audio level analysis', () { test('reads peak and RMS from the final astats overall summary', () { const logs = '''