diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index 420401d1..f0519b73 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -184,6 +184,11 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { return; } try { + final playbackCover = + normalizeOptionalString(_localCoverPath) ?? + normalizeOptionalString(_embeddedCoverPreviewPath) ?? + normalizeOptionalString(_coverUrl) ?? + ''; await ref .read(playbackProvider.notifier) .playLocalPath( @@ -191,7 +196,7 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { title: trackName, artist: artistName, album: albumName, - coverUrl: _coverUrl ?? '', + coverUrl: playbackCover, ); } catch (e) { if (context.mounted) { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 7d505dd1..4b1c1a1e 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -21,6 +21,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; @@ -124,6 +125,7 @@ class _TrackMetadataScreenState extends ConsumerState late LocalLibraryItem? _currentLocalLibraryItem; late int? _currentNavigationIndex; Map? _editedMetadata; + String? _resolvedAudioFormat; String? _embeddedCoverPreviewPath; static final RegExp _lrcTimestampPattern = RegExp( r'^\[\d{2}:\d{2}\.\d{2,3}\]', @@ -324,6 +326,19 @@ class _TrackMetadataScreenState extends ConsumerState filePath == cleanFilePath && _hasPath(cachedPath)) { setState(() => _embeddedCoverPreviewPath = cachedPath); + } else if (mounted && + generation == _metadataLoadGeneration && + filePath == cleanFilePath) { + final localCoverExists = + _hasPath(_localCoverPath) && await fileExists(_localCoverPath!); + if (!mounted || + generation != _metadataLoadGeneration || + filePath != cleanFilePath) { + return; + } + if (!localCoverExists && !_hasPath(_coverUrl)) { + unawaited(_refreshEmbeddedCoverPreview()); + } } } } @@ -376,9 +391,13 @@ class _TrackMetadataScreenState extends ConsumerState final resolvedBitDepth = readPositiveInt(metadata['bit_depth']); final resolvedSampleRate = readPositiveInt(metadata['sample_rate']); - final resolvedFormat = _normalizeAudioFormatValue( - metadata['audio_codec']?.toString() ?? metadata['format']?.toString(), - ); + final resolvedFormat = detectedAudioFormatFromMetadata(metadata); + final storedFormat = _isLocalItem + ? _localLibraryItem?.format + : _downloadItem?.format; + final formatChanged = + resolvedFormat != null && + resolvedFormat != normalizeAudioFormatValue(storedFormat); final resolvedBitrate = _isBitrateFormatValue(resolvedFormat) ? _readPlausibleBitrateKbps( metadata['bitrate'] ?? metadata['bit_rate'], @@ -475,6 +494,7 @@ class _TrackMetadataScreenState extends ConsumerState if ((resolvedBitDepth != null || resolvedSampleRate != null || + resolvedFormat != null || fileHasTitle || fileHasArtist || fileHasAlbumArtist || @@ -493,6 +513,7 @@ class _TrackMetadataScreenState extends ConsumerState isPlaceholderQualityLabel(_quality)) && mounted) { setState(() { + _resolvedAudioFormat = resolvedFormat; _editedMetadata = { ...?_editedMetadata, // ignore: use_null_aware_elements @@ -552,10 +573,11 @@ class _TrackMetadataScreenState extends ConsumerState ); }); } - } else if (_isLocalItem && needsDuration) { + } else if (_isLocalItem && (needsDuration || formatChanged)) { await LibraryDatabase.instance.updateAudioMetadata( _localLibraryItem!.id, duration: resolvedDuration, + format: formatChanged ? resolvedFormat : null, ); await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } @@ -753,7 +775,8 @@ class _TrackMetadataScreenState extends ConsumerState int? get _audioBitrate => _isLocalItem ? _localLibraryItem!.bitrate : _downloadItem?.bitrate; String? get _storedAudioFormat => - _isLocalItem ? _localLibraryItem?.format : _downloadItem?.format; + _resolvedAudioFormat ?? + (_isLocalItem ? _localLibraryItem?.format : _downloadItem?.format); String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; @@ -782,24 +805,6 @@ class _TrackMetadataScreenState extends ConsumerState String? get _quality => _isLocalItem ? null : _downloadItem!.quality; - String? _normalizeAudioFormatValue(String? value) { - final normalized = normalizeOptionalString( - value, - )?.toLowerCase().replaceAll('-', '_'); - return switch (normalized) { - 'flac' => 'flac', - 'alac' => 'alac', - 'aac' || 'mp4a' => 'aac', - 'eac3' || 'ec_3' => 'eac3', - 'ac3' || 'ac_3' => 'ac3', - 'ac4' || 'ac_4' => 'ac4', - 'mp3' => 'mp3', - 'opus' || 'ogg' => 'opus', - 'm4a' || 'mp4' => 'm4a', - _ => null, - }; - } - int? _readPlausibleBitrateKbps(dynamic value) { final parsed = readPositiveInt(value); if (parsed == null) return null; @@ -816,7 +821,7 @@ class _TrackMetadataScreenState extends ConsumerState 'mp3', 'opus', 'm4a', - }.contains(_normalizeAudioFormatValue(value)); + }.contains(normalizeAudioFormatValue(value)); } String? _usableStoredQuality(String? quality) { @@ -842,7 +847,7 @@ class _TrackMetadataScreenState extends ConsumerState int? bitrateKbps, String? storedQuality, }) { - final normalizedFormat = _normalizeAudioFormatValue(format); + final normalizedFormat = normalizeAudioFormatValue(format); final formatLabel = normalizedFormat == null ? normalizeOptionalString(format)?.toUpperCase() : _formatLabelForRaw(normalizedFormat); @@ -1082,6 +1087,7 @@ class _TrackMetadataScreenState extends ConsumerState _embeddedLyricsChecked = false; _hasLoadedResolvedAudioMetadata = false; _editedMetadata = null; + _resolvedAudioFormat = null; _embeddedCoverPreviewPath = null; }); diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 834be0d2..a0c02357 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -4,6 +4,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; @@ -1844,6 +1845,7 @@ class LibraryDatabase { int? bitDepth, int? sampleRate, int? bitrate, + String? format, }) async { final values = {}; if (duration != null && duration > 0) { @@ -1858,6 +1860,10 @@ class LibraryDatabase { if (bitrate != null && bitrate > 0) { values['bitrate'] = bitrate; } + final normalizedFormat = normalizeAudioFormatValue(format); + if (normalizedFormat != null) { + values['format'] = normalizedFormat; + } if (values.isEmpty) return; final db = await database; @@ -2049,23 +2055,7 @@ class LibraryDatabase { } String _normalizeConvertedFormat(String targetFormat) { - switch (targetFormat.trim().toLowerCase()) { - case 'alac': - return 'm4a'; - case 'aac': - return 'aac'; - case 'flac': - return 'flac'; - case 'opus': - return 'opus'; - case 'wav': - return 'wav'; - case 'aiff': - case 'aif': - return 'aiff'; - default: - return 'mp3'; - } + return normalizeAudioFormatValue(targetFormat) ?? 'mp3'; } int? _convertedBitrate({ diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 9902a499..90ef8a4e 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -54,6 +54,8 @@ String? normalizeAudioFormatValue(String? value) { return switch (normalized) { 'flac' => 'flac', 'alac' => 'alac', + 'wav' || 'wave' => 'wav', + 'aiff' || 'aif' || 'aifc' => 'aiff', 'aac' || 'mp4a' => 'aac', 'eac3' || 'ec_3' => 'eac3', 'ac3' || 'ac_3' => 'ac3', @@ -65,6 +67,17 @@ String? normalizeAudioFormatValue(String? value) { }; } +/// Resolves the actual audio codec reported by native metadata probing, while +/// falling back to the container format when the codec is absent or generic. +/// +/// This distinction matters for MP4/M4A files: the same container may hold +/// AAC, ALAC, Dolby, or other codecs. +String? detectedAudioFormatFromMetadata(Map metadata) { + final codec = normalizeAudioFormatValue(metadata['audio_codec']?.toString()); + if (codec != null) return codec; + return normalizeAudioFormatValue(metadata['format']?.toString()); +} + bool isLossyAudioFormat(String? value) { return const { 'aac', diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 5dbb24f1..a3d72d99 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -586,6 +586,21 @@ void main() { }); group('audio conversion utils', () { + test('distinguishes an ALAC codec from its M4A container', () { + expect(normalizeAudioFormatValue('ALAC'), 'alac'); + expect( + detectedAudioFormatFromMetadata({ + 'audio_codec': 'alac', + 'format': 'm4a', + }), + 'alac', + ); + expect( + detectedAudioFormatFromMetadata({'audio_codec': '', 'format': 'alac'}), + 'alac', + ); + }); + test( 'detects Dolby formats from stored scan format before file extension', () {