import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; /// Shows the batch format-conversion sheet for [selectedItems] and runs the /// conversion when confirmed. Handles both history-backed and local-backed /// items, including SAF-hosted files. /// /// [onSheetOpen] / [onSheetClosed] let the caller hide and restore any /// selection UI around the sheet; [onSheetClosed] receives whether a /// conversion was started. Future showBatchConvertSheet( BuildContext context, WidgetRef ref, List selectedItems, { required VoidCallback onExitSelectionMode, VoidCallback? onSheetOpen, Future Function(bool didStartConversion)? onSheetClosed, }) async { final sourceFormats = {}; final sourceBitDepths = []; final sourceSampleRates = []; for (final item in selectedItems) { final sourceFormat = convertibleAudioSourceFormat( storedFormat: item.localItem?.format ?? item.historyItem?.format, filePath: item.filePath, fileName: item.historyItem?.safFileName, ); if (sourceFormat != null) sourceFormats.add(sourceFormat); sourceBitDepths.add(item.historyItem?.bitDepth ?? item.localItem?.bitDepth); sourceSampleRates.add( item.historyItem?.sampleRate ?? item.localItem?.sampleRate, ); } final formats = audioConversionTargetFormats .where( (target) => sourceFormats.any( (source) => canConvertAudioFormat(sourceFormat: source, targetFormat: target), ), ) .toList(); if (formats.isEmpty) return; var didStartConversion = false; // Resolve localized strings up front; the builder must not look up // Localizations via a possibly deactivated context. final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle; final sheetConfirmLabel = context.l10n.selectionConvertCount( selectedItems.length, ); onSheetOpen?.call(); await showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (sheetContext) => BatchConvertSheet( formats: formats, title: sheetTitle, confirmLabel: sheetConfirmLabel, sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), onConvert: (format, bitrate, losslessQuality, losslessProcessing) { didStartConversion = true; Navigator.pop(sheetContext); _performBatchConversion( context, ref, selectedItems: selectedItems, targetFormat: format, bitrate: bitrate, losslessQuality: losslessQuality, losslessProcessing: losslessProcessing, onExitSelectionMode: onExitSelectionMode, ); }, ), ); await onSheetClosed?.call(didStartConversion); } Future _performBatchConversion( BuildContext context, WidgetRef ref, { required List selectedItems, required String targetFormat, required String bitrate, LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), LosslessConversionProcessing losslessProcessing = const LosslessConversionProcessing(), required VoidCallback onExitSelectionMode, }) async { final convertibleItems = []; for (final item in selectedItems) { 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; } convertibleItems.add(item); } if (convertibleItems.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), ); } return; } final isLossless = isLosslessConversionTarget(targetFormat); final losslessLabels = context.l10n.losslessConversionLabels; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( isLossless && losslessQuality.hasCaps ? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped( convertibleItems.length, targetFormat, losslessQualityLabel( losslessQuality, originalLabel: losslessLabels.original, originalQualityLabel: losslessLabels.originalQuality, ), ) : isLossless ? context.l10n.selectionBatchConvertConfirmMessageLossless( convertibleItems.length, targetFormat, ) : context.l10n.selectionBatchConvertConfirmMessage( convertibleItems.length, targetFormat, bitrate, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(context.l10n.trackConvertFormat), ), ], ), ); if (confirmed != true || !context.mounted) return; int successCount = 0; final total = convertibleItems.length; final historyDb = HistoryDatabase.instance; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; var cancelled = false; BatchProgressDialog.show( context: context, title: context.l10n.trackConvertConverting, total: total, icon: Icons.transform, onCancel: () { cancelled = true; BatchProgressDialog.dismiss(context); }, ); for (int i = 0; i < total; i++) { if (!context.mounted || cancelled) break; final item = convertibleItems[i]; BatchProgressDialog.update(current: i + 1, detail: item.trackName); try { final metadata = { 'TITLE': item.trackName, 'ARTIST': item.artistName, 'ALBUM': item.albumName, }; try { final result = await PlatformBridge.readFileMetadata(item.filePath); if (result['error'] == null) { mergePlatformMetadataForTagEmbed(target: metadata, source: result); } } catch (_) {} await ensureLyricsMetadataForConversion( metadata: metadata, sourcePath: item.filePath, shouldEmbedLyrics: shouldEmbedLyrics, trackName: item.trackName, artistName: item.artistName, spotifyId: item.historyItem?.spotifyId ?? '', durationMs: ((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) * 1000, ); String? coverPath; try { final tempDir = await getTemporaryDirectory(); final coverOutput = '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; final coverResult = await PlatformBridge.extractCoverToFile( item.filePath, coverOutput, ); if (coverResult['error'] == null) { coverPath = coverOutput; } } catch (_) {} String workingPath = item.filePath; final isSaf = isContentUri(item.filePath); String? safTempPath; if (isSaf) { safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath); if (safTempPath == null) continue; workingPath = safTempPath; } final newPath = await FFmpegService.convertAudioFormat( inputPath: workingPath, targetFormat: targetFormat.toLowerCase(), bitrate: bitrate, metadata: metadata, coverPath: coverPath, artistTagMode: settings.artistTagMode, deleteOriginal: !isSaf, sourceBitDepth: item.historyItem?.bitDepth ?? item.localItem?.bitDepth, losslessQuality: losslessQuality, losslessProcessing: losslessProcessing, ); if (coverPath != null) { try { await File(coverPath).delete(); } catch (_) {} } if (newPath == null) { if (safTempPath != null) { try { await File(safTempPath).delete(); } catch (_) {} } continue; } final sourceBitDepth = item.historyItem?.bitDepth ?? item.localItem?.bitDepth; final sourceSampleRate = item.historyItem?.sampleRate ?? item.localItem?.sampleRate; final isLosslessOutput = isLosslessConversionTarget(targetFormat); int? convertedBitDepth; int? convertedSampleRate; if (isLosslessOutput) { try { final convertedMetadata = await PlatformBridge.readFileMetadata( newPath, ); if (convertedMetadata['error'] == null) { convertedBitDepth = readPositiveInt(convertedMetadata['bit_depth']); convertedSampleRate = readPositiveInt( convertedMetadata['sample_rate'], ); } } catch (_) {} convertedBitDepth ??= losslessQuality.effectiveBitDepth(sourceBitDepth); convertedSampleRate ??= losslessQuality.effectiveSampleRate( sourceSampleRate, ); } final newQuality = convertedAudioQualityLabel( targetFormat: targetFormat, bitrate: bitrate, labels: losslessLabels, losslessQuality: losslessQuality, actualBitDepth: convertedBitDepth, actualSampleRate: convertedSampleRate, ); if (isSaf && item.historyItem != null) { final hi = item.historyItem!; final treeUri = hi.downloadTreeUri; final relativeDir = hi.safRelativeDir ?? ''; if (treeUri != null && treeUri.isNotEmpty) { final oldFileName = hi.safFileName ?? ''; final dotIdx = oldFileName.lastIndexOf('.'); final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; final convTarget = convertTargetExtAndMime(targetFormat); final newExt = convTarget.ext; final mimeType = convTarget.mime; final newFileName = '$baseName$newExt'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, relativeDir: relativeDir, fileName: newFileName, mimeType: mimeType, srcPath: newPath, ); if (safUri == null || safUri.isEmpty) { try { await File(newPath).delete(); } catch (_) {} if (safTempPath != null) { try { await File(safTempPath).delete(); } catch (_) {} } continue; } 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, ), newBitDepth: convertedBitDepth, newSampleRate: convertedSampleRate, clearAudioSpecs: !isLosslessOutput, ); } try { await File(newPath).delete(); } catch (_) {} if (safTempPath != null) { try { await File(safTempPath).delete(); } catch (_) {} } } else if (isSaf && item.localItem != null) { final uri = Uri.parse(item.filePath); final pathSegments = uri.pathSegments; String? treeUri; String relativeDir = ''; String oldFileName = ''; final treeIdx = pathSegments.indexOf('tree'); final docIdx = pathSegments.indexOf('document'); if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { final treeId = pathSegments[treeIdx + 1]; treeUri = 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; } if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); final slashIdx = docPath.lastIndexOf('/'); if (slashIdx >= 0) { oldFileName = docPath.substring(slashIdx + 1); final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length ? Uri.decodeFull(pathSegments[treeIdx + 1]) : ''; if (treeId.isNotEmpty && docPath.startsWith(treeId)) { final afterTree = docPath.substring(treeId.length); final trimmed = afterTree.startsWith('/') ? afterTree.substring(1) : afterTree; final lastSlash = trimmed.lastIndexOf('/'); relativeDir = lastSlash >= 0 ? trimmed.substring(0, lastSlash) : ''; } } else { oldFileName = docPath; } } if (treeUri != null && oldFileName.isNotEmpty) { final dotIdx = oldFileName.lastIndexOf('.'); final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; final convTarget = convertTargetExtAndMime(targetFormat); final newExt = convTarget.ext; final mimeType = convTarget.mime; final newFileName = '$baseName$newExt'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, relativeDir: relativeDir, fileName: newFileName, mimeType: mimeType, srcPath: newPath, ); if (safUri == null || safUri.isEmpty) { try { await File(newPath).delete(); } catch (_) {} if (safTempPath != null) { try { await File(safTempPath).delete(); } catch (_) {} } continue; } if (!isSameContentUri(item.filePath, safUri)) { try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} } await LibraryDatabase.instance.replaceWithConvertedItem( item: item.localItem!, newFilePath: safUri, targetFormat: targetFormat, bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, ); } try { await File(newPath).delete(); } catch (_) {} if (safTempPath != null) { try { await File(safTempPath).delete(); } catch (_) {} } } else if (item.historyItem != null) { await historyDb.updateFilePath( item.historyItem!.id, newPath, newQuality: newQuality, newFormat: normalizedConvertedAudioFormat(targetFormat), newBitrate: convertedAudioBitrateKbps( targetFormat: targetFormat, bitrate: bitrate, ), newBitDepth: convertedBitDepth, newSampleRate: convertedSampleRate, clearAudioSpecs: !isLosslessOutput, ); } else if (item.localItem != null) { await LibraryDatabase.instance.replaceWithConvertedItem( item: item.localItem!, newFilePath: newPath, targetFormat: targetFormat, bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, ); } successCount++; } catch (_) {} } ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); ref.read(localLibraryProvider.notifier).reloadFromStorage(); onExitSelectionMode(); if (context.mounted) { if (!cancelled) { BatchProgressDialog.dismiss(context); } ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( context.l10n.selectionBatchConvertSuccess( successCount, total, targetFormat, ), ), ), ); } } /// Batch-scans loudness and writes ReplayGain tags to [selectedItems]. /// /// [onConfirmOpen] / [onConfirmClosed] let the caller hide and restore any /// selection UI around the confirmation dialog; [onConfirmClosed] receives /// whether the user confirmed. Future runBatchReplayGain( BuildContext context, List selectedItems, { required VoidCallback onExitSelectionMode, VoidCallback? onConfirmOpen, Future Function(bool confirmed)? onConfirmClosed, }) async { if (selectedItems.isEmpty) return; onConfirmOpen?.call(); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(ctx.l10n.replayGainBatchConfirmTitle), content: Text( ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.l10n.dialogCancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(ctx.l10n.replayGainBatchConfirmTitle), ), ], ), ); await onConfirmClosed?.call(confirmed == true); if (confirmed != true || !context.mounted) return; var cancelled = false; int successCount = 0; final total = selectedItems.length; BatchProgressDialog.show( context: context, title: context.l10n.replayGainBatchAnalyzing, total: total, icon: Icons.graphic_eq, onCancel: () { cancelled = true; BatchProgressDialog.dismiss(context); }, ); for (int i = 0; i < total; i++) { if (!context.mounted || cancelled) break; final item = selectedItems[i]; BatchProgressDialog.update(current: i + 1, detail: item.trackName); try { final ok = await ReplayGainService.applyToFile(item.filePath); if (ok) successCount++; } catch (_) {} } onExitSelectionMode(); if (!context.mounted) return; if (!cancelled) { BatchProgressDialog.dismiss(context); } ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.replayGainBatchSuccess(successCount, total)), ), ); }