mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 07:18:49 +02:00
feat(ui): add shared widgets, utils, and models for deduplicated screens
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.
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user