mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 05:10:28 +02:00
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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+$');
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user