mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
local_album and the queue batch action carried byte-identical ~110-line copies
of the FFmpeg re-enrich apply+SAF-writeback+temp-cleanup flow (differing only in
helper names). Extract one applyFfmpegReEnrichResult() so the SAF temp-file
cleanup — the class of bug fixed in fb7d101 — has a single source. The
track-detail copy stays: it drives snackbars and does not preserve metadata.
428 lines
12 KiB
Dart
428 lines
12 KiB
Dart
part of 'queue_tab.dart';
|
|
|
|
extension _QueueTabBatchActions on _QueueTabState {
|
|
Future<bool> _reEnrichQueueLocalTrack(
|
|
LocalLibraryItem item, {
|
|
List<String>? updateFields,
|
|
}) async {
|
|
final durationMs = (item.duration ?? 0) * 1000;
|
|
final settings = ref.read(settingsProvider);
|
|
final artistTagMode = settings.artistTagMode;
|
|
await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend();
|
|
final request = <String, dynamic>{
|
|
'file_path': item.filePath,
|
|
'cover_url': '',
|
|
'max_quality': true,
|
|
'embed_lyrics': settings.embedLyrics,
|
|
'lyrics_mode': settings.lyricsMode,
|
|
'artist_tag_mode': artistTagMode,
|
|
'spotify_id': '',
|
|
'track_name': item.trackName,
|
|
'artist_name': item.artistName,
|
|
'album_name': item.albumName,
|
|
'album_artist': item.albumArtist ?? '',
|
|
'track_number': item.trackNumber ?? 0,
|
|
'disc_number': item.discNumber ?? 0,
|
|
'release_date': item.releaseDate ?? '',
|
|
'isrc': item.isrc ?? '',
|
|
'genre': item.genre ?? '',
|
|
'label': '',
|
|
'copyright': '',
|
|
'duration_ms': durationMs,
|
|
'search_online': true,
|
|
// ignore: use_null_aware_elements
|
|
if (updateFields != null) 'update_fields': updateFields,
|
|
};
|
|
|
|
final result = await PlatformBridge.reEnrichFile(request);
|
|
final method = result['method'] as String?;
|
|
if (method == 'native') {
|
|
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
|
|
await writeReEnrichSidecarLrc(
|
|
audioFilePath: item.filePath,
|
|
reEnrichResult: result,
|
|
);
|
|
return true;
|
|
}
|
|
if (method == 'ffmpeg') {
|
|
return applyFfmpegReEnrichResult(
|
|
item: item,
|
|
result: result,
|
|
artistTagMode: artistTagMode,
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
List<LocalLibraryItem> _selectedFlacEligibleLocalItems(
|
|
List<UnifiedLibraryItem> allItems,
|
|
) {
|
|
final selectedItems = _selectedItemsFromAll(allItems);
|
|
return selectedItems
|
|
.map((item) => item.localItem)
|
|
.whereType<LocalLibraryItem>()
|
|
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
Future<void> _queueSelectedLocalAsFlac(
|
|
List<UnifiedLibraryItem> allItems,
|
|
) async {
|
|
final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems);
|
|
|
|
if (selectedLocalItems.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(context.l10n.queueFlacAction),
|
|
content: Text(
|
|
context.l10n.queueFlacConfirmMessage(selectedLocalItems.length),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(context.l10n.queueFlacAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true || !mounted) {
|
|
return;
|
|
}
|
|
|
|
final settings = ref.read(settingsProvider);
|
|
final extensionState = ref.read(extensionProvider);
|
|
final includeExtensions =
|
|
settings.useExtensionProviders &&
|
|
extensionState.extensions.any(
|
|
(ext) => ext.enabled && ext.hasMetadataProvider,
|
|
);
|
|
final targetService = LocalTrackRedownloadService.preferredFlacService(
|
|
settings,
|
|
extensionState,
|
|
);
|
|
if (targetService.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
|
|
);
|
|
return;
|
|
}
|
|
final targetQuality =
|
|
LocalTrackRedownloadService.preferredFlacQualityForService(
|
|
targetService,
|
|
extensionState,
|
|
);
|
|
|
|
final matchedTracks = <Track>[];
|
|
var skippedCount = 0;
|
|
final total = selectedLocalItems.length;
|
|
|
|
var cancelled = false;
|
|
BatchProgressDialog.show(
|
|
context: context,
|
|
title: context.l10n.queueFlacAction,
|
|
total: total,
|
|
icon: Icons.queue_music,
|
|
onCancel: () {
|
|
cancelled = true;
|
|
BatchProgressDialog.dismiss(context);
|
|
},
|
|
);
|
|
|
|
for (var i = 0; i < total; i++) {
|
|
if (!mounted || cancelled) break;
|
|
|
|
BatchProgressDialog.update(
|
|
current: i + 1,
|
|
detail: selectedLocalItems[i].trackName,
|
|
);
|
|
|
|
try {
|
|
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
|
selectedLocalItems[i],
|
|
includeExtensions: includeExtensions,
|
|
);
|
|
if (resolution.canQueue && resolution.match != null) {
|
|
matchedTracks.add(resolution.match!);
|
|
} else {
|
|
skippedCount++;
|
|
}
|
|
} catch (_) {
|
|
skippedCount++;
|
|
}
|
|
}
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (!cancelled) {
|
|
BatchProgressDialog.dismiss(context);
|
|
}
|
|
|
|
if (matchedTracks.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
ref
|
|
.read(downloadQueueProvider.notifier)
|
|
.addMultipleToQueue(
|
|
matchedTracks,
|
|
targetService,
|
|
qualityOverride: targetQuality,
|
|
);
|
|
|
|
final summary = skippedCount == 0
|
|
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
|
|
: context.l10n.queueFlacQueuedWithSkipped(
|
|
matchedTracks.length,
|
|
skippedCount,
|
|
);
|
|
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(summary)));
|
|
_setState(() {
|
|
_selectedIds.clear();
|
|
_isSelectionMode = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _reEnrichSelectedLocalFromQueue(
|
|
List<UnifiedLibraryItem> allItems,
|
|
) async {
|
|
final selectedItems = _selectedItemsFromAll(allItems);
|
|
final selectedLocalItems = selectedItems
|
|
.map((item) => item.localItem)
|
|
.whereType<LocalLibraryItem>()
|
|
.toList(growable: false);
|
|
|
|
if (selectedLocalItems.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
// Hide the selection overlay: set the flag (prevents build() from
|
|
// re-inserting via postFrameCallback) and remove the entry immediately.
|
|
_setState(() => _isSelectionMode = false);
|
|
_hideSelectionOverlay();
|
|
|
|
final selection = await showReEnrichFieldDialog(
|
|
context,
|
|
selectedCount: selectedLocalItems.length,
|
|
);
|
|
|
|
if (selection == null || !mounted) {
|
|
// Cancelled — restore selection mode; the next build cycle will
|
|
// re-create the overlay via _syncSelectionOverlay in postFrameCallback.
|
|
if (mounted) _setState(() => _isSelectionMode = true);
|
|
return;
|
|
}
|
|
|
|
final updateFields = selection.isAll ? null : selection.fields;
|
|
|
|
var successCount = 0;
|
|
final total = selectedLocalItems.length;
|
|
|
|
var cancelled = false;
|
|
BatchProgressDialog.show(
|
|
context: context,
|
|
title: context.l10n.trackReEnrichProgress,
|
|
total: total,
|
|
icon: Icons.auto_fix_high,
|
|
onCancel: () {
|
|
cancelled = true;
|
|
BatchProgressDialog.dismiss(context);
|
|
},
|
|
);
|
|
|
|
for (var i = 0; i < total; i++) {
|
|
if (!mounted || cancelled) break;
|
|
final item = selectedLocalItems[i];
|
|
|
|
BatchProgressDialog.update(
|
|
current: i + 1,
|
|
detail: '${item.trackName} - ${item.artistName}',
|
|
);
|
|
|
|
try {
|
|
final ok = await _reEnrichQueueLocalTrack(
|
|
item,
|
|
updateFields: updateFields,
|
|
);
|
|
if (ok) {
|
|
successCount++;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
final settings = ref.read(settingsProvider);
|
|
final localLibraryPath = settings.localLibraryPath.trim();
|
|
final iosBookmark = settings.localLibraryBookmark;
|
|
try {
|
|
if (localLibraryPath.isNotEmpty &&
|
|
!ref.read(localLibraryProvider).isScanning) {
|
|
await ref
|
|
.read(localLibraryProvider.notifier)
|
|
.startScan(
|
|
localLibraryPath,
|
|
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
|
);
|
|
} else {
|
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
|
}
|
|
} catch (_) {
|
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
|
}
|
|
|
|
_exitSelectionMode();
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (!cancelled) {
|
|
BatchProgressDialog.dismiss(context);
|
|
}
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
final failedCount = total - successCount;
|
|
final summary = failedCount <= 0
|
|
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
|
|
: context.l10n.trackReEnrichSuccessWithFailures(
|
|
successCount,
|
|
total,
|
|
failedCount,
|
|
);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(summary)));
|
|
}
|
|
|
|
/// Share selected tracks via system share sheet
|
|
Future<void> _shareSelected(List<UnifiedLibraryItem> allItems) async {
|
|
final itemsById = {for (final item in allItems) item.id: item};
|
|
final safUris = <String>[];
|
|
final filesToShare = <XFile>[];
|
|
|
|
for (final id in _selectedIds) {
|
|
final item = itemsById[id];
|
|
if (item == null) continue;
|
|
final path = item.filePath;
|
|
if (isContentUri(path)) {
|
|
if (await fileExists(path)) safUris.add(path);
|
|
} else if (await fileExists(path)) {
|
|
filesToShare.add(XFile(path));
|
|
}
|
|
}
|
|
|
|
if (safUris.isEmpty && filesToShare.isEmpty) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.selectionShareNoFiles)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (safUris.isNotEmpty) {
|
|
try {
|
|
if (safUris.length == 1) {
|
|
await PlatformBridge.shareContentUri(safUris.first);
|
|
} else {
|
|
await PlatformBridge.shareMultipleContentUris(safUris);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (filesToShare.isNotEmpty) {
|
|
await SharePlus.instance.share(ShareParams(files: filesToShare));
|
|
}
|
|
}
|
|
|
|
Future<void> _showBatchConvertSheet(
|
|
BuildContext context,
|
|
List<UnifiedLibraryItem> allItems,
|
|
) {
|
|
return showBatchConvertSheet(
|
|
context,
|
|
ref,
|
|
_selectedItemsFromAll(allItems),
|
|
onExitSelectionMode: _exitSelectionMode,
|
|
onSheetOpen: () {
|
|
_suppressSelectionOverlay = true;
|
|
_hideSelectionOverlay();
|
|
_hidePlaylistSelectionOverlay();
|
|
},
|
|
onSheetClosed: (didStartConversion) async {
|
|
// Wait out the sheet's exit animation before restoring the toolbar so
|
|
// it doesn't pop in front of the still-closing sheet.
|
|
await Future<void>.delayed(const Duration(milliseconds: 260));
|
|
if (!mounted) {
|
|
_suppressSelectionOverlay = false;
|
|
return;
|
|
}
|
|
_suppressSelectionOverlay = false;
|
|
if (didStartConversion) return;
|
|
if (_isSelectionMode) {
|
|
_syncSelectionOverlay(
|
|
items: allItems,
|
|
bottomPadding: MediaQuery.of(this.context).padding.bottom,
|
|
);
|
|
} else if (_isPlaylistSelectionMode) {
|
|
_syncPlaylistSelectionOverlay(
|
|
playlists: ref.read(libraryCollectionsProvider).playlists,
|
|
bottomPadding: MediaQuery.of(this.context).padding.bottom,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _runBatchReplayGain(List<UnifiedLibraryItem> allItems) {
|
|
return runBatchReplayGain(
|
|
context,
|
|
_selectedItemsFromAll(allItems),
|
|
onExitSelectionMode: _exitSelectionMode,
|
|
onConfirmOpen: () {
|
|
_suppressSelectionOverlay = true;
|
|
_hideSelectionOverlay();
|
|
_hidePlaylistSelectionOverlay();
|
|
},
|
|
onConfirmClosed: (confirmed) async {
|
|
if (!mounted) {
|
|
_suppressSelectionOverlay = false;
|
|
return;
|
|
}
|
|
if (confirmed) {
|
|
_suppressSelectionOverlay = false;
|
|
return;
|
|
}
|
|
// Restore after the dialog's exit animation.
|
|
await Future<void>.delayed(const Duration(milliseconds: 220));
|
|
_suppressSelectionOverlay = false;
|
|
if (!mounted) return;
|
|
if (_isSelectionMode) {
|
|
_syncSelectionOverlay(
|
|
items: allItems,
|
|
bottomPadding: MediaQuery.paddingOf(context).bottom,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|