mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 15:28:48 +02:00
Single homes for logic that was copy-pasted across screens: the settings collapsing header, album detail header (online screen's design as the reference), selection pill button + bottom-bar chrome, disc separator chip, error card, cover URL upgrade, byte/clock formatting, duration extraction, UnifiedLibraryItem (moved out of the queue_tab library so other screens can import it), and the batch convert/ReplayGain engine keyed on it.
609 lines
20 KiB
Dart
609 lines
20 KiB
Dart
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<void> showBatchConvertSheet(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
List<UnifiedLibraryItem> selectedItems, {
|
|
required VoidCallback onExitSelectionMode,
|
|
VoidCallback? onSheetOpen,
|
|
Future<void> Function(bool didStartConversion)? onSheetClosed,
|
|
}) async {
|
|
final sourceFormats = <String>{};
|
|
final sourceBitDepths = <int?>[];
|
|
final sourceSampleRates = <int?>[];
|
|
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<void>(
|
|
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<void> _performBatchConversion(
|
|
BuildContext context,
|
|
WidgetRef ref, {
|
|
required List<UnifiedLibraryItem> selectedItems,
|
|
required String targetFormat,
|
|
required String bitrate,
|
|
LosslessConversionQuality losslessQuality = const LosslessConversionQuality(),
|
|
LosslessConversionProcessing losslessProcessing =
|
|
const LosslessConversionProcessing(),
|
|
required VoidCallback onExitSelectionMode,
|
|
}) async {
|
|
final convertibleItems = <UnifiedLibraryItem>[];
|
|
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<bool>(
|
|
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 = <String, String>{
|
|
'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<void> runBatchReplayGain(
|
|
BuildContext context,
|
|
List<UnifiedLibraryItem> selectedItems, {
|
|
required VoidCallback onExitSelectionMode,
|
|
VoidCallback? onConfirmOpen,
|
|
Future<void> Function(bool confirmed)? onConfirmClosed,
|
|
}) async {
|
|
if (selectedItems.isEmpty) return;
|
|
|
|
onConfirmOpen?.call();
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
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)),
|
|
),
|
|
);
|
|
}
|