diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index fadd22d0..732e0368 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; @@ -950,8 +951,12 @@ class _DownloadedAlbumScreenState extends ConsumerState { : item.filePath.toLowerCase(); final ext = nameToCheck.endsWith('.flac') ? 'FLAC' + : nameToCheck.endsWith('.alac') + ? 'ALAC' : nameToCheck.endsWith('.m4a') ? 'M4A' + : (nameToCheck.endsWith('.aac') || nameToCheck.endsWith('.mp4a')) + ? 'AAC' : nameToCheck.endsWith('.mp3') ? 'MP3' : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) @@ -960,11 +965,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (ext != null) sourceFormats.add(ext); } - final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { + final formats = ['ALAC', 'FLAC', 'AAC', 'MP3', 'Opus'].where((target) { return sourceFormats.any((src) { if (src == target) return false; final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; - final isLosslessSource = src == 'FLAC' || src == 'M4A'; + final isLosslessSource = src == 'FLAC' || src == 'ALAC' || src == 'M4A'; if (isLosslessTarget && !isLosslessSource) return false; return true; }); @@ -975,9 +980,15 @@ class _DownloadedAlbumScreenState extends ConsumerState { String selectedFormat = formats.first; bool isLosslessTarget = selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + String defaultBitrateForFormat(String format) { + if (format == 'Opus') return '128k'; + if (format == 'AAC') return '256k'; + return '320k'; + } + String selectedBitrate = isLosslessTarget ? '320k' - : (selectedFormat == 'Opus' ? '128k' : '320k'); + : defaultBitrateForFormat(selectedFormat); showModalBottomSheet( context: context, @@ -1039,9 +1050,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { isLosslessTarget = format == 'ALAC' || format == 'FLAC'; if (!isLosslessTarget) { - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; + selectedBitrate = defaultBitrateForFormat( + format, + ); } }); } @@ -1316,6 +1327,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { mimeType = 'audio/opus'; break; case 'alac': + case 'aac': newExt = '.m4a'; mimeType = 'audio/mp4'; break; @@ -1350,14 +1362,21 @@ class _DownloadedAlbumScreenState extends ConsumerState { continue; } - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} + if (!isSameContentUri(item.filePath, safUri)) { + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + } await historyDb.updateFilePath( item.id, safUri, newSafFileName: newFileName, newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), clearAudioSpecs: true, ); } @@ -1374,6 +1393,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { item.id, newPath, newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), clearAudioSpecs: true, ); } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e2cd9a3c..919506f4 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; @@ -1176,52 +1177,38 @@ class _LocalAlbumScreenState extends ConsumerState { for (final id in _selectedIds) { final item = tracksById[id]; if (item == null) continue; - String? ext; - if (item.format != null && item.format!.isNotEmpty) { - final fmt = item.format!.toLowerCase(); - if (fmt == 'flac') { - ext = 'FLAC'; - } else if (fmt == 'm4a') { - ext = 'M4A'; - } else if (fmt == 'mp3') { - ext = 'MP3'; - } else if (fmt == 'opus' || fmt == 'ogg') { - ext = 'Opus'; - } - } - if (ext == null) { - final lower = item.filePath.toLowerCase(); - if (lower.endsWith('.flac')) { - ext = 'FLAC'; - } else if (lower.endsWith('.m4a')) { - ext = 'M4A'; - } else if (lower.endsWith('.mp3')) { - ext = 'MP3'; - } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { - ext = 'Opus'; - } - } - if (ext != null) sourceFormats.add(ext); + final sourceFormat = convertibleAudioSourceFormat( + storedFormat: item.format, + filePath: item.filePath, + ); + if (sourceFormat != null) sourceFormats.add(sourceFormat); } - final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { - return sourceFormats.any((src) { - if (src == target) return false; - final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; - final isLosslessSource = src == 'FLAC' || src == 'M4A'; - if (isLosslessTarget && !isLosslessSource) return false; - return true; - }); - }).toList(); + final formats = audioConversionTargetFormats + .where( + (target) => sourceFormats.any( + (source) => canConvertAudioFormat( + sourceFormat: source, + targetFormat: target, + ), + ), + ) + .toList(); if (formats.isEmpty) return; String selectedFormat = formats.first; bool isLosslessTarget = selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + String defaultBitrateForFormat(String format) { + if (format == 'Opus') return '128k'; + if (format == 'AAC') return '256k'; + return '320k'; + } + String selectedBitrate = isLosslessTarget ? '320k' - : (selectedFormat == 'Opus' ? '128k' : '320k'); + : defaultBitrateForFormat(selectedFormat); showModalBottomSheet( context: context, @@ -1283,9 +1270,9 @@ class _LocalAlbumScreenState extends ConsumerState { isLosslessTarget = format == 'ALAC' || format == 'FLAC'; if (!isLosslessTarget) { - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; + selectedBitrate = defaultBitrateForFormat( + format, + ); } }); } @@ -1381,39 +1368,17 @@ class _LocalAlbumScreenState extends ConsumerState { for (final id in _selectedIds) { final item = tracksById[id]; if (item == null) continue; - // Detect current format: prefer item.format field (works for SAF too), - // fall back to file extension for regular paths - String? currentFormat; - if (item.format != null && item.format!.isNotEmpty) { - final fmt = item.format!.toLowerCase(); - if (fmt == 'flac') { - currentFormat = 'FLAC'; - } else if (fmt == 'm4a') { - currentFormat = 'M4A'; - } else if (fmt == 'mp3') { - currentFormat = 'MP3'; - } else if (fmt == 'opus' || fmt == 'ogg') { - currentFormat = 'Opus'; - } + final currentFormat = convertibleAudioSourceFormat( + storedFormat: item.format, + filePath: item.filePath, + ); + if (currentFormat == null || + !canConvertAudioFormat( + sourceFormat: currentFormat, + targetFormat: targetFormat, + )) { + continue; } - if (currentFormat == null) { - // Fallback: try file extension (works for regular paths) - final lower = item.filePath.toLowerCase(); - if (lower.endsWith('.flac')) { - currentFormat = 'FLAC'; - } else if (lower.endsWith('.m4a')) { - currentFormat = 'M4A'; - } else if (lower.endsWith('.mp3')) { - currentFormat = 'MP3'; - } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { - currentFormat = 'Opus'; - } - } - if (currentFormat == null || currentFormat == targetFormat) continue; - final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; - final isLosslessSource = - currentFormat == 'FLAC' || currentFormat == 'M4A'; - if (isLosslessTarget && !isLosslessSource) continue; selected.add(item); } @@ -1608,6 +1573,7 @@ class _LocalAlbumScreenState extends ConsumerState { mimeType = 'audio/opus'; break; case 'alac': + case 'aac': newExt = '.m4a'; mimeType = 'audio/mp4'; break; @@ -1642,9 +1608,11 @@ class _LocalAlbumScreenState extends ConsumerState { continue; } - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} + if (!isSameContentUri(item.filePath, safUri)) { + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + } await localDb.replaceWithConvertedItem( item: item, newFilePath: safUri, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 1fd2b66d..01ee5b25 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -4839,46 +4840,39 @@ class _QueueTabState extends ConsumerState { for (final id in _selectedIds) { final item = itemsById[id]; if (item == null) continue; - String nameToCheck; - if (item.historyItem?.safFileName != null && - item.historyItem!.safFileName!.isNotEmpty) { - nameToCheck = item.historyItem!.safFileName!.toLowerCase(); - } else if (item.localItem?.format != null && - item.localItem!.format!.isNotEmpty) { - nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; - } else { - nameToCheck = item.filePath.toLowerCase(); - } - final ext = nameToCheck.endsWith('.flac') - ? 'FLAC' - : nameToCheck.endsWith('.m4a') - ? 'M4A' - : nameToCheck.endsWith('.mp3') - ? 'MP3' - : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) - ? 'Opus' - : null; - if (ext != null) sourceFormats.add(ext); + final sourceFormat = convertibleAudioSourceFormat( + storedFormat: item.localItem?.format ?? item.historyItem?.format, + filePath: item.filePath, + fileName: item.historyItem?.safFileName, + ); + if (sourceFormat != null) sourceFormats.add(sourceFormat); } - final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { - return sourceFormats.any((src) { - if (src == target) return false; - final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; - final isLosslessSource = src == 'FLAC' || src == 'M4A'; - if (isLosslessTarget && !isLosslessSource) return false; - return true; - }); - }).toList(); + final formats = audioConversionTargetFormats + .where( + (target) => sourceFormats.any( + (source) => canConvertAudioFormat( + sourceFormat: source, + targetFormat: target, + ), + ), + ) + .toList(); if (formats.isEmpty) return; String selectedFormat = formats.first; bool isLosslessTarget = selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + String defaultBitrateForFormat(String format) { + if (format == 'Opus') return '128k'; + if (format == 'AAC') return '256k'; + return '320k'; + } + String selectedBitrate = isLosslessTarget ? '320k' - : (selectedFormat == 'Opus' ? '128k' : '320k'); + : defaultBitrateForFormat(selectedFormat); var didStartConversion = false; _hideSelectionOverlay(); @@ -4944,9 +4938,9 @@ class _QueueTabState extends ConsumerState { isLosslessTarget = format == 'ALAC' || format == 'FLAC'; if (!isLosslessTarget) { - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; + selectedBitrate = defaultBitrateForFormat( + format, + ); } }); } @@ -5057,29 +5051,18 @@ class _QueueTabState extends ConsumerState { for (final id in _selectedIds) { final item = itemsById[id]; if (item == null) continue; - String nameToCheck; - if (item.historyItem?.safFileName != null && - item.historyItem!.safFileName!.isNotEmpty) { - nameToCheck = item.historyItem!.safFileName!.toLowerCase(); - } else if (item.localItem?.format != null && - item.localItem!.format!.isNotEmpty) { - nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; - } else { - nameToCheck = item.filePath.toLowerCase(); + final sourceFormat = convertibleAudioSourceFormat( + storedFormat: item.localItem?.format ?? item.historyItem?.format, + filePath: item.filePath, + fileName: item.historyItem?.safFileName, + ); + if (sourceFormat == null || + !canConvertAudioFormat( + sourceFormat: sourceFormat, + targetFormat: targetFormat, + )) { + continue; } - final ext = nameToCheck.endsWith('.flac') - ? 'FLAC' - : nameToCheck.endsWith('.m4a') - ? 'M4A' - : nameToCheck.endsWith('.mp3') - ? 'MP3' - : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) - ? 'Opus' - : null; - if (ext == null || ext == targetFormat) continue; - final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; - final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; - if (isLosslessTarget && !isLosslessSource) continue; selectedItems.add(item); } @@ -5247,6 +5230,7 @@ class _QueueTabState extends ConsumerState { mimeType = 'audio/opus'; break; case 'alac': + case 'aac': newExt = '.m4a'; mimeType = 'audio/mp4'; break; @@ -5281,15 +5265,22 @@ class _QueueTabState extends ConsumerState { continue; } - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} + if (!isSameContentUri(item.filePath, safUri)) { + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + } await historyDb.updateFilePath( hi.id, safUri, newSafFileName: newFileName, newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), clearAudioSpecs: true, ); } @@ -5352,6 +5343,7 @@ class _QueueTabState extends ConsumerState { mimeType = 'audio/opus'; break; case 'alac': + case 'aac': newExt = '.m4a'; mimeType = 'audio/mp4'; break; @@ -5386,9 +5378,11 @@ class _QueueTabState extends ConsumerState { continue; } - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} + if (!isSameContentUri(item.filePath, safUri)) { + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + } await LibraryDatabase.instance.replaceWithConvertedItem( item: item.localItem!, newFilePath: safUri, @@ -5410,6 +5404,11 @@ class _QueueTabState extends ConsumerState { item.historyItem!.id, newPath, newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), clearAudioSpecs: true, ); } else if (item.localItem != null) { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 4e710644..ec4a014b 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; @@ -3375,6 +3376,7 @@ class _TrackMetadataScreenState extends ConsumerState { final lower = cleanFilePath.toLowerCase(); return lower.endsWith('.flac') || lower.endsWith('.m4a') || + lower.endsWith('.aac') || lower.endsWith('.mp3') || lower.endsWith('.opus') || lower.endsWith('.ogg'); @@ -3409,8 +3411,12 @@ class _TrackMetadataScreenState extends ConsumerState { case 'flac': return 'FLAC'; case 'alac': + return 'ALAC'; case 'm4a': return 'M4A'; + case 'aac': + case 'mp4a': + return 'AAC'; case 'mp3': return 'MP3'; case 'opus': @@ -3421,6 +3427,7 @@ class _TrackMetadataScreenState extends ConsumerState { final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.flac')) return 'FLAC'; if (lower.endsWith('.m4a')) return 'M4A'; + if (lower.endsWith('.aac')) return 'AAC'; if (lower.endsWith('.mp3')) return 'MP3'; if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; if (lower.endsWith('.cue')) return 'CUE'; @@ -3554,8 +3561,12 @@ class _TrackMetadataScreenState extends ConsumerState { final formats = []; if (currentFormat == 'FLAC') { formats.addAll(['ALAC', 'AAC', 'MP3', 'Opus']); - } else if (currentFormat == 'M4A') { + } else if (currentFormat == 'ALAC') { formats.addAll(['FLAC', 'AAC', 'MP3', 'Opus']); + } else if (currentFormat == 'M4A') { + formats.addAll(['ALAC', 'FLAC', 'AAC', 'MP3', 'Opus']); + } else if (currentFormat == 'AAC') { + formats.addAll(['MP3', 'Opus']); } else if (currentFormat == 'MP3') { formats.addAll(['AAC', 'Opus']); } else if (currentFormat == 'Opus') { @@ -4448,11 +4459,15 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - final deletedOriginal = await PlatformBridge.safDelete( - cleanFilePath, - ).catchError((_) => false); - if (deletedOriginal != true) { - _log.w('Converted SAF file created but failed deleting original URI'); + if (!isSameContentUri(cleanFilePath, safUri)) { + final deletedOriginal = await PlatformBridge.safDelete( + cleanFilePath, + ).catchError((_) => false); + if (deletedOriginal != true) { + _log.w( + 'Converted SAF file created but failed deleting original URI', + ); + } } if (!_isLocalItem) { @@ -4461,6 +4476,11 @@ class _TrackMetadataScreenState extends ConsumerState { safUri, newSafFileName: newFileName, newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), clearAudioSpecs: true, ); await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); @@ -4488,6 +4508,11 @@ class _TrackMetadataScreenState extends ConsumerState { _downloadItem!.id, newPath, newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), clearAudioSpecs: true, ); await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 26d251b2..cf84b1e8 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -925,6 +925,8 @@ class HistoryDatabase { String? newQuality, int? newBitDepth, int? newSampleRate, + int? newBitrate, + String? newFormat, bool clearAudioSpecs = false, }) async { final db = await database; @@ -935,9 +937,18 @@ class HistoryDatabase { if (newQuality != null) { values['quality'] = newQuality; } + if (newFormat != null) { + values['format'] = newFormat; + } + if (newBitrate != null) { + values['bitrate'] = newBitrate; + } if (clearAudioSpecs) { values['bit_depth'] = null; values['sample_rate'] = null; + if (newBitrate == null) { + values['bitrate'] = null; + } } else { if (newBitDepth != null) { values['bit_depth'] = newBitDepth; diff --git a/lib/utils/audio_conversion_utils.dart b/lib/utils/audio_conversion_utils.dart new file mode 100644 index 00000000..da178a2f --- /dev/null +++ b/lib/utils/audio_conversion_utils.dart @@ -0,0 +1,105 @@ +const List audioConversionTargetFormats = [ + 'ALAC', + 'FLAC', + 'AAC', + 'MP3', + 'Opus', +]; + +bool isLosslessConversionTarget(String targetFormat) { + final normalized = targetFormat.trim().toLowerCase(); + return normalized == 'alac' || normalized == 'flac'; +} + +bool isLosslessConversionSource(String sourceFormat) { + switch (sourceFormat.trim().toUpperCase()) { + case 'FLAC': + case 'ALAC': + case 'M4A': + return true; + default: + return false; + } +} + +bool canConvertAudioFormat({ + required String sourceFormat, + required String targetFormat, +}) { + if (sourceFormat.trim().toUpperCase() == targetFormat.trim().toUpperCase()) { + return false; + } + if (isLosslessConversionTarget(targetFormat) && + !isLosslessConversionSource(sourceFormat)) { + return false; + } + return true; +} + +String? convertibleAudioSourceFormat({ + String? storedFormat, + String? filePath, + String? fileName, +}) { + final fromStored = _convertibleAudioFormatLabel(storedFormat); + if (fromStored != null) return fromStored; + + final name = (fileName != null && fileName.trim().isNotEmpty) + ? fileName + : filePath; + if (name == null || name.trim().isEmpty) return null; + + final normalizedName = name.trim().toLowerCase(); + final dotIndex = normalizedName.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == normalizedName.length - 1) { + return null; + } + return _convertibleAudioFormatLabel(normalizedName.substring(dotIndex + 1)); +} + +String? _convertibleAudioFormatLabel(String? rawFormat) { + final format = rawFormat?.trim().toLowerCase(); + if (format == null || format.isEmpty) return null; + + switch (format) { + case 'flac': + return 'FLAC'; + case 'alac': + return 'ALAC'; + case 'm4a': + case 'mp4': + return 'M4A'; + case 'aac': + case 'mp4a': + return 'AAC'; + case 'mp3': + return 'MP3'; + case 'opus': + case 'ogg': + return 'Opus'; + case 'eac3': + case 'ec-3': + return 'EAC3'; + case 'ac3': + case 'ac-3': + return 'AC3'; + case 'ac4': + case 'ac-4': + return 'AC4'; + default: + return null; + } +} + +String normalizedConvertedAudioFormat(String targetFormat) { + return targetFormat.trim().toLowerCase(); +} + +int? convertedAudioBitrateKbps({ + required String targetFormat, + required String bitrate, +}) { + if (isLosslessConversionTarget(targetFormat)) return null; + final match = RegExp(r'(\d+)').firstMatch(bitrate); + return match != null ? int.tryParse(match.group(1)!) : null; +} diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index 1562a35d..a6a53c5b 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -229,6 +229,22 @@ bool isContentUri(String? path) { return path != null && path.startsWith('content://'); } +bool isSameContentUri(String? first, String? second) { + if (first == null || second == null) return false; + if (first == second) return true; + if (!isContentUri(first) || !isContentUri(second)) return false; + + String decode(String value) { + try { + return Uri.decodeFull(value); + } catch (_) { + return value; + } + } + + return decode(first) == decode(second); +} + /// Pattern matching CUE virtual path suffixes like #track01, #track12, etc. final _cueTrackSuffix = RegExp(r'#track\d+$'); diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 64dc6742..ebdd3129 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -15,10 +15,13 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; class AudioAnalysisData { + static const cacheVersion = 3; + final String filePath; final int fileSize; final int sampleRate; final int channels; + final String channelLayout; final int bitsPerSample; final double duration; final int bitrate; @@ -34,6 +37,7 @@ class AudioAnalysisData { required this.fileSize, required this.sampleRate, required this.channels, + this.channelLayout = '', required this.bitsPerSample, required this.duration, required this.bitrate, @@ -47,9 +51,11 @@ class AudioAnalysisData { Map toJson() => { 'filePath': filePath, + 'cacheVersion': cacheVersion, 'fileSize': fileSize, 'sampleRate': sampleRate, 'channels': channels, + 'channelLayout': channelLayout, 'bitsPerSample': bitsPerSample, 'duration': duration, 'bitrate': bitrate, @@ -66,6 +72,7 @@ class AudioAnalysisData { fileSize: json['fileSize'] as int, sampleRate: json['sampleRate'] as int, channels: json['channels'] as int, + channelLayout: json['channelLayout']?.toString() ?? '', bitsPerSample: json['bitsPerSample'] as int, duration: (json['duration'] as num).toDouble(), bitrate: json['bitrate'] as int, @@ -116,11 +123,20 @@ class _AudioAnalysisCardState extends State { '.flac', '.mp3', '.m4a', + '.mp4', '.aac', + '.ac3', + '.eac3', '.opus', '.ogg', '.wav', '.wma', + '.mka', + '.wv', + '.ape', + '.tta', + '.aif', + '.aiff', }; bool get _isSupported { @@ -268,11 +284,18 @@ class _AudioAnalysisCardState extends State { final json = Map.from( jsonDecode(await file.readAsString()) as Map, ); + if (json['cacheVersion'] != AudioAnalysisData.cacheVersion) { + return null; + } final cachedSize = json['fileSize'] as int; 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; } return AudioAnalysisData.fromJson(json); @@ -348,7 +371,7 @@ class _AudioAnalysisCardState extends State { await _decodeToPCM(workingPath, pcmPath, info.sampleRate); final pcmBytes = await File(pcmPath).readAsBytes(); - final result = await compute( + final spectrumResult = await compute( _analyzeInIsolate, _AnalysisParams( pcmBytes: pcmBytes, @@ -356,26 +379,30 @@ class _AudioAnalysisCardState extends State { bitsPerSample: info.bitsPerSample, ), ); - - final trueTotalSamples = - (info.duration * info.sampleRate * info.channels).round(); + final levelMetrics = await _runFullStreamLevelAnalysis(workingPath); + final peakAmplitude = + levelMetrics?.peakDb ?? spectrumResult.peakAmplitude; + final rmsLevel = levelMetrics?.rmsDb ?? spectrumResult.rmsLevel; + final dynamicRange = + levelMetrics?.dynamicRangeDb ?? (peakAmplitude - rmsLevel); return AudioAnalysisData( filePath: filePath, fileSize: info.fileSize, sampleRate: info.sampleRate, channels: info.channels, + channelLayout: info.channelLayout, bitsPerSample: info.bitsPerSample, duration: info.duration, bitrate: info.bitrate, bitDepth: info.bitsPerSample > 0 ? '${info.bitsPerSample}-bit' : 'N/A', - dynamicRange: result.dynamicRange, - peakAmplitude: result.peakAmplitude, - rmsLevel: result.rmsLevel, - totalSamples: trueTotalSamples, - spectrum: result.spectrum, + dynamicRange: dynamicRange, + peakAmplitude: peakAmplitude, + rmsLevel: rmsLevel, + totalSamples: info.totalSamples, + spectrum: spectrumResult.spectrum, ); } finally { try { @@ -415,25 +442,38 @@ class _AudioAnalysisCardState extends State { final sampleRate = int.tryParse(props['sample_rate']?.toString() ?? '') ?? 0; final channels = int.tryParse(props['channels']?.toString() ?? '') ?? 0; + final channelLayout = + props['channel_layout']?.toString() ?? + props['ch_layout']?.toString() ?? + ''; + final streamDuration = double.tryParse(props['duration']?.toString() ?? ''); + final containerDuration = double.tryParse(info.getDuration() ?? ''); final duration = - double.tryParse( - info.getDuration() ?? props['duration']?.toString() ?? '', - ) ?? + (streamDuration != null && streamDuration > 0 + ? streamDuration + : containerDuration) ?? 0; + final streamBitrate = int.tryParse(props['bit_rate']?.toString() ?? ''); + final containerBitrate = int.tryParse(info.getBitrate() ?? ''); final bitrate = - int.tryParse( - info.getBitrate() ?? props['bit_rate']?.toString() ?? '', - ) ?? - 0; + streamBitrate ?? + containerBitrate ?? + (duration > 0 && fileSize > 0 ? (fileSize * 8 / duration).round() : 0); - int bitsPerSample = - int.tryParse(props['bits_per_raw_sample']?.toString() ?? '') ?? 0; - if (bitsPerSample == 0) { + final codecName = props['codec_name']?.toString().toLowerCase() ?? ''; + final canReportStoredBitDepth = _codecHasStoredBitDepth(codecName); + + int bitsPerSample = 0; + if (canReportStoredBitDepth) { bitsPerSample = - int.tryParse(props['bits_per_sample']?.toString() ?? '') ?? 0; + int.tryParse(props['bits_per_raw_sample']?.toString() ?? '') ?? 0; + if (bitsPerSample == 0) { + bitsPerSample = + int.tryParse(props['bits_per_sample']?.toString() ?? '') ?? 0; + } } - if (bitsPerSample == 0) { + if (bitsPerSample == 0 && canReportStoredBitDepth) { final sampleFmt = props['sample_fmt']?.toString() ?? ''; if (sampleFmt.contains('16') || sampleFmt == 's16' || @@ -452,12 +492,117 @@ class _AudioAnalysisCardState extends State { fileSize: fileSize, sampleRate: sampleRate, channels: channels, + channelLayout: channelLayout, bitsPerSample: bitsPerSample, duration: duration, bitrate: bitrate, + totalSamples: _estimateTotalSamples( + props: props, + duration: duration, + sampleRate: sampleRate, + channels: channels, + ), ); } + int _estimateTotalSamples({ + required Map props, + required double duration, + required int sampleRate, + required int channels, + }) { + final nbSamples = int.tryParse(props['nb_samples']?.toString() ?? ''); + if (nbSamples != null && nbSamples > 0) { + return nbSamples; + } + + final durationTs = int.tryParse(props['duration_ts']?.toString() ?? ''); + final timeBase = props['time_base']?.toString() ?? ''; + if (durationTs != null && durationTs > 0 && timeBase.contains('/')) { + final parts = timeBase.split('/'); + final numerator = double.tryParse(parts[0]); + final denominator = double.tryParse(parts[1]); + if (numerator != null && + numerator > 0 && + denominator != null && + denominator > 0 && + sampleRate > 0) { + final seconds = durationTs * numerator / denominator; + return (seconds * sampleRate).round(); + } + } + + if (duration > 0 && sampleRate > 0) { + return (duration * sampleRate).round(); + } + return 0; + } + + bool _codecHasStoredBitDepth(String codecName) { + if (codecName.isEmpty) return false; + return codecName == 'flac' || + codecName == 'alac' || + codecName == 'wavpack' || + codecName == 'ape' || + codecName == 'tta' || + codecName.startsWith('pcm_'); + } + + Future<_LevelMetrics?> _runFullStreamLevelAnalysis(String inputPath) async { + await FFmpegKitConfig.setLogLevel(Level.avLogInfo); + try { + final session = await FFmpegKit.executeWithArguments([ + '-v', + 'info', + '-hide_banner', + '-nostats', + '-i', + inputPath, + '-map', + '0:a:0', + '-af', + 'astats=metadata=1:reset=0', + '-f', + 'null', + '-', + ]); + + final returnCode = await session.getReturnCode(); + if (!ReturnCode.isSuccess(returnCode)) { + return null; + } + + final logs = await session.getLogsAsString(); + final overallMatch = RegExp(r'Overall([\s\S]*)').firstMatch(logs); + final section = overallMatch?.group(1) ?? logs; + final peak = _parseLastAstatsValue(section, 'Peak level dB'); + final rms = _parseLastAstatsValue(section, 'RMS level dB'); + if (peak == null || rms == null) return null; + return _LevelMetrics( + peakDb: peak, + rmsDb: rms, + dynamicRangeDb: _parseLastAstatsValue(section, 'Dynamic range'), + ); + } finally { + await FFmpegKitConfig.setLogLevel(Level.avLogError); + } + } + + double? _parseLastAstatsValue(String text, String label) { + final matches = RegExp( + '${RegExp.escape(label)}:\\s*([-+]?\\d+(?:\\.\\d+)?)', + caseSensitive: false, + ).allMatches(text); + double? value; + for (final match in matches) { + final parsed = double.tryParse(match.group(1) ?? ''); + if (parsed != null && parsed.isFinite) { + value = parsed; + } + } + return value; + } + Future _decodeToPCM( String inputPath, String outputPath, @@ -651,17 +796,33 @@ class _MediaInfo { final int fileSize; final int sampleRate; final int channels; + final String channelLayout; final int bitsPerSample; final double duration; final int bitrate; + final int totalSamples; const _MediaInfo({ required this.fileSize, required this.sampleRate, required this.channels, + required this.channelLayout, required this.bitsPerSample, required this.duration, required this.bitrate, + required this.totalSamples, + }); +} + +class _LevelMetrics { + final double peakDb; + final double rmsDb; + final double? dynamicRangeDb; + + const _LevelMetrics({ + required this.peakDb, + required this.rmsDb, + this.dynamicRangeDb, }); } @@ -899,14 +1060,17 @@ class _AudioInfoCard extends StatelessWidget { value: data.bitDepth, cs: cs, ), + if (data.bitrate > 0) + _MetricChip( + icon: Icons.speed, + label: context.l10n.trackConvertBitrate, + value: _formatBitrate(data.bitrate), + cs: cs, + ), _MetricChip( icon: Icons.surround_sound, label: context.l10n.audioAnalysisChannels, - value: data.channels == 2 - ? context.l10n.audioAnalysisStereo - : data.channels == 1 - ? context.l10n.audioAnalysisMono - : '${data.channels}', + value: _formatChannels(context, data), cs: cs, ), _MetricChip( @@ -916,7 +1080,7 @@ class _AudioInfoCard extends StatelessWidget { cs: cs, ), _MetricChip( - icon: Icons.speed, + icon: Icons.multiline_chart, label: context.l10n.audioAnalysisNyquist, value: '${(nyquist / 1000).toStringAsFixed(1)} kHz', cs: cs, @@ -975,6 +1139,16 @@ class _AudioInfoCard extends StatelessWidget { return '$mins:${secs.toString().padLeft(2, '0')}'; } + String _formatChannels(BuildContext context, AudioAnalysisData data) { + final layout = data.channelLayout.trim(); + if (layout.isNotEmpty && layout != 'unknown') { + return data.channels > 0 ? '${data.channels} ($layout)' : layout; + } + if (data.channels == 2) return context.l10n.audioAnalysisStereo; + if (data.channels == 1) return context.l10n.audioAnalysisMono; + return data.channels > 0 ? '${data.channels}' : 'N/A'; + } + String _formatFileSize(int bytes) { if (bytes == 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; @@ -983,6 +1157,13 @@ class _AudioInfoCard extends StatelessWidget { return '${size.toStringAsFixed(1)} ${units[i]}'; } + String _formatBitrate(int bitsPerSecond) { + if (bitsPerSecond >= 1000000) { + return '${(bitsPerSecond / 1000000).toStringAsFixed(2)} Mbps'; + } + return '${(bitsPerSecond / 1000).round()} kbps'; + } + String _formatNumber(int n) { if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K';