feat: audio analysis rescan and AAC conversion support

Audio Analysis:
- Add rescan capability by bumping cache version
- Display channel layout (stereo, 5.1, etc.) and bitrate
- Use astats filter for more accurate peak/RMS measurements
- Support more formats: mp4, ac3, eac3, mka, wv, ape, tta, aif
- Only report bit depth for codecs that store it (FLAC, ALAC, WAV)
- Validate cache for SAF content:// URIs

Conversion:
- Add AAC as conversion target format
- Recognize ALAC as lossless source
- Prevent accidental deletion when source and target URI match
- Store format and bitrate in database after conversion

Utilities:
- Add audio_conversion_utils.dart for centralized conversion logic
- Add isSameContentUri() helper for safe URI comparison
This commit is contained in:
zarzet
2026-05-14 15:46:55 +07:00
parent 2a2d817314
commit ff86869c33
8 changed files with 506 additions and 177 deletions
+33 -9
View File
@@ -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<DownloadedAlbumScreen> {
: 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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
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<void>(
context: context,
@@ -1039,9 +1050,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
@@ -1316,6 +1327,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
@@ -1350,14 +1362,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
item.id,
newPath,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
}
+42 -74
View File
@@ -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<LocalAlbumScreen> {
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<void>(
context: context,
@@ -1283,9 +1270,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
@@ -1381,39 +1368,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
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<LocalAlbumScreen> {
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
@@ -1642,9 +1608,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
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,
+60 -61
View File
@@ -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<QueueTab> {
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<QueueTab> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
@@ -5057,29 +5051,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
@@ -5281,15 +5265,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
@@ -5386,9 +5378,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
item.historyItem!.id,
newPath,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
} else if (item.localItem != null) {
+31 -6
View File
@@ -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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
final formats = <String>[];
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
_downloadItem!.id,
newPath,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
+11
View File
@@ -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;
+105
View File
@@ -0,0 +1,105 @@
const List<String> 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;
}
+16
View File
@@ -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+$');
+208 -27
View File
@@ -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<String, dynamic> 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<AudioAnalysisCard> {
'.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<AudioAnalysisCard> {
final json = Map<String, dynamic>.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<AudioAnalysisCard> {
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<AudioAnalysisCard> {
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<AudioAnalysisCard> {
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<AudioAnalysisCard> {
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<dynamic, dynamic> 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<void> _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';