diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 0180553a..21344812 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -277,7 +277,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { List queueItems = const [], }) async { try { - await ref.read(playbackProvider.notifier).playHistoryQueue( + await ref + .read(playbackProvider.notifier) + .playHistoryQueue( queueItems.isNotEmpty ? queueItems : [track], startItem: track, ); @@ -708,11 +710,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ) { Widget placeholder() => Container( color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: 48, - color: colorScheme.onSurfaceVariant, - ), + child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant), ); if (embeddedCoverPath != null) { @@ -1106,6 +1104,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { ) { final tracksById = {for (final t in allTracks) t.id: t}; final sourceFormats = {}; + final sourceBitDepths = []; + final sourceSampleRates = []; for (final id in _selectedIds) { final item = tracksById[id]; if (item == null) continue; @@ -1115,6 +1115,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { fileName: item.safFileName, ); if (sourceFormat != null) sourceFormats.add(sourceFormat); + sourceBitDepths.add(item.bitDepth); + sourceSampleRates.add(item.sampleRate); } final formats = audioConversionTargetFormats @@ -1145,12 +1147,15 @@ class _DownloadedAlbumScreenState extends ConsumerState { formats: formats, title: sheetTitle, confirmLabel: sheetConfirmLabel, - onConvert: (format, bitrate) { + sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), + sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), + onConvert: (format, bitrate, losslessQuality) { Navigator.pop(sheetContext); _performBatchConversion( allTracks: allTracks, targetFormat: format, bitrate: bitrate, + losslessQuality: losslessQuality, ); }, ), @@ -1161,6 +1166,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { required List allTracks, required String targetFormat, required String bitrate, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), }) async { final tracksById = {for (final t in allTracks) t.id: t}; final selected = []; @@ -1197,7 +1204,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - isLossless + isLossless && losslessQuality.hasCaps + ? 'Convert ${selected.length} tracks to $targetFormat (${losslessQualityLabel(losslessQuality)})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.' + : isLossless ? context.l10n.selectionBatchConvertConfirmMessageLossless( selected.length, targetFormat, @@ -1226,10 +1235,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { int successCount = 0; final total = selected.length; final historyDb = HistoryDatabase.instance; - final newQuality = - isLosslessConversionTarget(targetFormat) - ? '${targetFormat.toUpperCase()} Lossless' - : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; @@ -1306,6 +1311,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { coverPath: coverPath, artistTagMode: settings.artistTagMode, deleteOriginal: !isSaf, + sourceBitDepth: item.bitDepth, + losslessQuality: losslessQuality, ); if (coverPath != null) { @@ -1323,6 +1330,38 @@ class _DownloadedAlbumScreenState extends ConsumerState { continue; } + final isLosslessOutput = isLosslessConversionTarget(targetFormat); + int? convertedBitDepth; + int? convertedSampleRate; + if (isLosslessOutput) { + try { + final convertedMetadata = await PlatformBridge.readFileMetadata( + newPath, + ); + if (convertedMetadata['error'] == null) { + convertedBitDepth = readPositiveAudioInt( + convertedMetadata['bit_depth'], + ); + convertedSampleRate = readPositiveAudioInt( + convertedMetadata['sample_rate'], + ); + } + } catch (_) {} + convertedBitDepth ??= losslessQuality.effectiveBitDepth( + item.bitDepth, + ); + convertedSampleRate ??= losslessQuality.effectiveSampleRate( + item.sampleRate, + ); + } + final newQuality = convertedAudioQualityLabel( + targetFormat: targetFormat, + bitrate: bitrate, + losslessQuality: losslessQuality, + actualBitDepth: convertedBitDepth, + actualSampleRate: convertedSampleRate, + ); + if (isSaf) { final treeUri = item.downloadTreeUri; final relativeDir = item.safRelativeDir ?? ''; @@ -1372,7 +1411,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { targetFormat: targetFormat, bitrate: bitrate, ), - clearAudioSpecs: true, + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, ); } try { @@ -1393,7 +1434,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { targetFormat: targetFormat, bitrate: bitrate, ), - clearAudioSpecs: true, + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, ); } @@ -1438,9 +1481,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: Text(ctx.l10n.replayGainBatchConfirmTitle), - content: Text( - ctx.l10n.replayGainBatchConfirmMessage(selected.length), - ), + content: Text(ctx.l10n.replayGainBatchConfirmMessage(selected.length)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), @@ -1490,9 +1531,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.replayGainBatchSuccess(successCount, total), - ), + content: Text(context.l10n.replayGainBatchSuccess(successCount, total)), ), ); } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 699d9ae2..d5b4eef2 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -428,8 +428,8 @@ class _LocalAlbumScreenState extends ConsumerState { cacheWidth: cacheWidth, gaplessPlayback: true, errorBuilder: (_, _, _) => Container( - color: - colorScheme.surfaceContainerHighest, + color: colorScheme + .surfaceContainerHighest, child: Icon( Icons.album, size: 48, @@ -1283,6 +1283,8 @@ class _LocalAlbumScreenState extends ConsumerState { ) { final tracksById = {for (final t in allTracks) t.id: t}; final sourceFormats = {}; + final sourceBitDepths = []; + final sourceSampleRates = []; for (final id in _selectedIds) { final item = tracksById[id]; if (item == null) continue; @@ -1291,6 +1293,8 @@ class _LocalAlbumScreenState extends ConsumerState { filePath: item.filePath, ); if (sourceFormat != null) sourceFormats.add(sourceFormat); + sourceBitDepths.add(item.bitDepth); + sourceSampleRates.add(item.sampleRate); } final formats = audioConversionTargetFormats @@ -1321,12 +1325,15 @@ class _LocalAlbumScreenState extends ConsumerState { formats: formats, title: sheetTitle, confirmLabel: sheetConfirmLabel, - onConvert: (format, bitrate) { + sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), + sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), + onConvert: (format, bitrate, losslessQuality) { Navigator.pop(sheetContext); _performBatchConversion( allTracks: allTracks, targetFormat: format, bitrate: bitrate, + losslessQuality: losslessQuality, ); }, ), @@ -1337,6 +1344,8 @@ class _LocalAlbumScreenState extends ConsumerState { required List allTracks, required String targetFormat, required String bitrate, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), }) async { final tracksById = {for (final t in allTracks) t.id: t}; final selected = []; @@ -1372,7 +1381,9 @@ class _LocalAlbumScreenState extends ConsumerState { builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - isLossless + isLossless && losslessQuality.hasCaps + ? 'Convert ${selected.length} tracks to $targetFormat (${losslessQualityLabel(losslessQuality)})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.' + : isLossless ? context.l10n.selectionBatchConvertConfirmMessageLossless( selected.length, targetFormat, @@ -1476,6 +1487,8 @@ class _LocalAlbumScreenState extends ConsumerState { coverPath: coverPath, artistTagMode: settings.artistTagMode, deleteOriginal: !isSaf, + sourceBitDepth: item.bitDepth, + losslessQuality: losslessQuality, ); if (coverPath != null) { @@ -1493,6 +1506,31 @@ class _LocalAlbumScreenState extends ConsumerState { continue; } + final isLosslessOutput = isLosslessConversionTarget(targetFormat); + int? convertedBitDepth; + int? convertedSampleRate; + if (isLosslessOutput) { + try { + final convertedMetadata = await PlatformBridge.readFileMetadata( + newPath, + ); + if (convertedMetadata['error'] == null) { + convertedBitDepth = readPositiveAudioInt( + convertedMetadata['bit_depth'], + ); + convertedSampleRate = readPositiveAudioInt( + convertedMetadata['sample_rate'], + ); + } + } catch (_) {} + convertedBitDepth ??= losslessQuality.effectiveBitDepth( + item.bitDepth, + ); + convertedSampleRate ??= losslessQuality.effectiveSampleRate( + item.sampleRate, + ); + } + if (isSaf) { final uri = Uri.parse(item.filePath); final pathSegments = uri.pathSegments; @@ -1575,6 +1613,8 @@ class _LocalAlbumScreenState extends ConsumerState { newFilePath: safUri, targetFormat: targetFormat, bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, ); } @@ -1592,6 +1632,8 @@ class _LocalAlbumScreenState extends ConsumerState { newFilePath: newPath, targetFormat: targetFormat, bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, ); } @@ -1636,9 +1678,7 @@ class _LocalAlbumScreenState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: Text(ctx.l10n.replayGainBatchConfirmTitle), - content: Text( - ctx.l10n.replayGainBatchConfirmMessage(selected.length), - ), + content: Text(ctx.l10n.replayGainBatchConfirmMessage(selected.length)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), @@ -1688,9 +1728,7 @@ class _LocalAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.replayGainBatchSuccess(successCount, total), - ), + content: Text(context.l10n.replayGainBatchSuccess(successCount, total)), ), ); }