refactor(batch): share local track actions and phase settings

Use shared coordinators for queueing FLAC replacements and batch metadata enrichment from album and queue screens. Keep selection lifecycle handling in each screen and synchronize a settings snapshot once per phase.

Cover cancellation, partial failure, screen disposal, and settings changes between preview and apply.
This commit is contained in:
zarzet
2026-09-06 14:04:05 +07:00
parent 7c12f548d7
commit 09c31d9dba
6 changed files with 853 additions and 701 deletions
+9 -7
View File
@@ -206,22 +206,24 @@ class SettingsNotifier extends Notifier<AppSettings> {
unawaited(syncLyricsSettingsToBackend());
}
Future<void> syncLyricsSettingsToBackend() async {
Future<void> syncLyricsSettingsToBackend({AppSettings? settings}) async {
final snapshot = settings ?? state;
if (!PlatformBridge.supportsCoreBackend) return;
try {
await PlatformBridge.setLyricsProviders(state.lyricsProviders);
await PlatformBridge.setLyricsProviders(snapshot.lyricsProviders);
} catch (e) {
_log.w('Failed to sync lyrics providers to backend: $e');
}
try {
await PlatformBridge.setLyricsFetchOptions({
'include_translation_netease': state.lyricsIncludeTranslationNetease,
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
'musixmatch_language': state.musixmatchLanguage,
'include_translation_netease': snapshot.lyricsIncludeTranslationNetease,
'include_romanization_netease':
snapshot.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': snapshot.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': snapshot.lyricsAppleElrcWordSync,
'musixmatch_language': snapshot.musixmatchLanguage,
});
} catch (e) {
_log.w('Failed to sync lyrics fetch options to backend: $e');
+20 -343
View File
@@ -1,30 +1,21 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/services/local_track_batch_actions.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
@@ -432,41 +423,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
);
}
Future<bool> _reEnrichLocalTrack(
LocalLibraryItem item, {
required List<String> updateFields,
required Map<String, dynamic> resolvedMetadata,
}) async {
final settings = ref.read(settingsProvider);
final artistTagMode = settings.artistTagMode;
await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend();
final request = buildBatchReEnrichRequest(
item: item,
settings: settings,
updateFields: updateFields,
resolvedMetadata: resolvedMetadata,
);
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> _selectedFlacEligibleItems(
List<LocalLibraryItem> allTracks,
) {
@@ -478,310 +434,31 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
.toList(growable: false);
}
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
final selected = _selectedFlacEligibleItems(allTracks);
if (selected.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(context.l10n.queueFlacConfirmMessage(selected.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)),
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) =>
queueLocalTracksAsFlac(
context,
ref,
_selectedFlacEligibleItems(allTracks),
isActive: () => mounted,
onComplete: exitSelectionMode,
);
return;
}
final targetQuality =
LocalTrackRedownloadService.preferredFlacQualityForService(
targetService,
extensionState,
);
final matchedTracks = <Track>[];
var skippedCount = 0;
final total = selected.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: selected[i].trackName);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selected[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)));
exitSelectionMode();
}
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
for (final id in selectedIds) {
final item = tracksById[id];
if (item != null) {
selected.add(item);
}
}
if (selected.isEmpty) {
return;
}
// The bar uses AnimatedPositioned (250ms), so wait for the slide-out.
setState(() => isSelectionMode = false);
await Future<void>.delayed(const Duration(milliseconds: 300));
if (!mounted) return;
final selection = await showReEnrichFieldDialog(
final tracksById = {for (final track in allTracks) track.id: track};
final selected = [for (final id in selectedIds) ?tracksById[id]];
await reEnrichLocalTracks(
context,
selectedCount: selected.length,
);
if (selection == null || !mounted) {
// Cancelled — restore selection mode (IDs are still intact).
if (mounted) setState(() => isSelectionMode = true);
return;
}
await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend();
if (!mounted) return;
final settings = ref.read(settingsProvider);
final previews = <BatchReEnrichPreview>[];
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: selection.usesManualValues
? context.l10n.trackReEnrichPreparing
: context.l10n.trackReEnrichSearching,
total: selected.length,
icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
ref,
selected,
isActive: () => mounted,
onSelectionHide: () async {
setState(() => isSelectionMode = false);
// Allow the selection bar's 250 ms slide-out to finish.
await Future<void>.delayed(const Duration(milliseconds: 300));
},
onSelectionRestore: () => setState(() => isSelectionMode = true),
onComplete: exitSelectionMode,
);
for (var i = 0; i < selected.length; i++) {
if (!mounted || cancelled) break;
final item = selected[i];
BatchProgressDialog.update(
current: i + 1,
detail: '${item.trackName} - ${item.artistName}',
);
final updateFields = selection.updateFieldsFor(item);
if (updateFields.isEmpty) continue;
if (selection.usesManualValues) {
final preview = buildManualBatchReEnrichPreview(item, selection);
if (preview != null) previews.add(preview);
continue;
}
try {
final result = await PlatformBridge.reEnrichFile(
buildBatchReEnrichRequest(
item: item,
settings: settings,
updateFields: updateFields,
previewOnly: true,
),
);
final rawMetadata = result['enriched_metadata'];
if (result['method'] != 'preview' || rawMetadata is! Map) continue;
final enrichedMetadata = rawMetadata.map(
(key, value) => MapEntry(key.toString(), value),
);
final changes = buildReEnrichMetadataChanges(
item,
enrichedMetadata,
updateFields,
);
if (changes.isEmpty) continue;
previews.add(
BatchReEnrichPreview(
item: item,
updateFields: updateFields,
enrichedMetadata: enrichedMetadata,
changes: changes,
),
);
} catch (_) {}
}
if (!mounted) return;
if (!cancelled) BatchProgressDialog.dismiss(context);
if (cancelled) {
setState(() => isSelectionMode = true);
return;
}
if (previews.isEmpty) {
setState(() => isSelectionMode = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)),
);
return;
}
final confirmed = await showReEnrichReviewSheet(
context,
previews: previews,
);
if (!confirmed || !mounted) {
if (mounted) setState(() => isSelectionMode = true);
return;
}
var successCount = 0;
final total = previews.length;
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 preview = previews[i];
BatchProgressDialog.update(
current: i + 1,
detail: '${preview.item.trackName} - ${preview.item.artistName}',
);
try {
final ok = await _reEnrichLocalTrack(
preview.item,
updateFields: preview.updateFields,
resolvedMetadata: preview.enrichedMetadata,
);
if (ok) successCount++;
} catch (_) {}
}
if (!mounted) return;
if (!cancelled) BatchProgressDialog.dismiss(context);
try {
if (!ref.read(localLibraryProvider).isScanning) {
await ref.read(localLibraryProvider.notifier).scanAllSources();
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
} catch (_) {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
exitSelectionMode();
if (!mounted) {
return;
}
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)));
}
List<UnifiedLibraryItem> _selectedUnifiedItems(
+1 -7
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart';
import 'package:spotiflac_android/services/local_track_batch_actions.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/track_card.dart';
@@ -17,13 +18,10 @@ import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/ordered_range_selection.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -36,14 +34,10 @@ import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/favorite_artists_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
+20 -344
View File
@@ -1,41 +1,6 @@
part of 'queue_tab.dart';
extension _QueueTabBatchActions on _QueueTabState {
Future<bool> _reEnrichQueueLocalTrack(
LocalLibraryItem item, {
required List<String> updateFields,
required Map<String, dynamic> resolvedMetadata,
}) async {
final settings = ref.read(settingsProvider);
final artistTagMode = settings.artistTagMode;
await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend();
final request = buildBatchReEnrichRequest(
item: item,
settings: settings,
updateFields: updateFields,
resolvedMetadata: resolvedMetadata,
);
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,
) {
@@ -47,321 +12,32 @@ extension _QueueTabBatchActions on _QueueTabState {
.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)),
Future<void> _queueSelectedLocalAsFlac(List<UnifiedLibraryItem> allItems) =>
queueLocalTracksAsFlac(
context,
ref,
_selectedFlacEligibleLocalItems(allItems),
isActive: () => mounted,
onComplete: _exitSelectionMode,
);
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;
_selectionAnchorId = null;
});
}
Future<void> _reEnrichSelectedLocalFromQueue(
List<UnifiedLibraryItem> allItems,
) async {
final selectedItems = _selectedItemsFromAll(allItems);
final selectedLocalItems = selectedItems
) => reEnrichLocalTracks(
context,
ref,
_selectedItemsFromAll(allItems)
.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;
}
await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend();
if (!mounted) return;
final settings = ref.read(settingsProvider);
final previews = <BatchReEnrichPreview>[];
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: selection.usesManualValues
? context.l10n.trackReEnrichPreparing
: context.l10n.trackReEnrichSearching,
total: selectedLocalItems.length,
icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (var i = 0; i < selectedLocalItems.length; i++) {
if (!mounted || cancelled) break;
final item = selectedLocalItems[i];
BatchProgressDialog.update(
current: i + 1,
detail: '${item.trackName} - ${item.artistName}',
);
final updateFields = selection.updateFieldsFor(item);
if (updateFields.isEmpty) continue;
if (selection.usesManualValues) {
final preview = buildManualBatchReEnrichPreview(item, selection);
if (preview != null) previews.add(preview);
continue;
}
try {
final result = await PlatformBridge.reEnrichFile(
buildBatchReEnrichRequest(
item: item,
settings: settings,
updateFields: updateFields,
previewOnly: true,
),
);
final rawMetadata = result['enriched_metadata'];
if (result['method'] != 'preview' || rawMetadata is! Map) continue;
final enrichedMetadata = rawMetadata.map(
(key, value) => MapEntry(key.toString(), value),
);
final changes = buildReEnrichMetadataChanges(
item,
enrichedMetadata,
updateFields,
);
if (changes.isEmpty) continue;
previews.add(
BatchReEnrichPreview(
item: item,
updateFields: updateFields,
enrichedMetadata: enrichedMetadata,
changes: changes,
),
);
} catch (_) {}
}
if (!mounted) return;
if (!cancelled) BatchProgressDialog.dismiss(context);
if (cancelled) {
_setState(() => _isSelectionMode = true);
return;
}
if (previews.isEmpty) {
_setState(() => _isSelectionMode = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)),
);
return;
}
final confirmed = await showReEnrichReviewSheet(
context,
previews: previews,
);
if (!confirmed || !mounted) {
if (mounted) _setState(() => _isSelectionMode = true);
return;
}
var successCount = 0;
final total = previews.length;
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 preview = previews[i];
BatchProgressDialog.update(
current: i + 1,
detail: '${preview.item.trackName} - ${preview.item.artistName}',
);
try {
final ok = await _reEnrichQueueLocalTrack(
preview.item,
updateFields: preview.updateFields,
resolvedMetadata: preview.enrichedMetadata,
);
if (ok) successCount++;
} catch (_) {}
}
if (!mounted) return;
if (!cancelled) BatchProgressDialog.dismiss(context);
try {
if (!ref.read(localLibraryProvider).isScanning) {
await ref.read(localLibraryProvider.notifier).scanAllSources();
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
} catch (_) {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
_exitSelectionMode();
if (!mounted) {
return;
}
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)));
}
.toList(growable: false),
isActive: () => mounted,
onSelectionHide: () async {
_setState(() => _isSelectionMode = false);
_hideSelectionOverlay();
},
onSelectionRestore: () => _setState(() => _isSelectionMode = true),
onComplete: _exitSelectionMode,
);
/// Share selected tracks via system share sheet
Future<void> _shareSelected(List<UnifiedLibraryItem> allItems) async {
+414
View File
@@ -0,0 +1,414 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart';
Future<void> queueLocalTracksAsFlac(
BuildContext context,
WidgetRef ref,
List<LocalLibraryItem> selected, {
required bool Function() isActive,
required VoidCallback onComplete,
}) async {
if (selected.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(context.l10n.queueFlacConfirmMessage(selected.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 || !context.mounted || !isActive()) {
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 total = selected.length;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.queueFlacAction,
total: total,
icon: Icons.queue_music,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
final matches = await matchLocalTracksForFlac(
selected,
resolve: (item) => LocalTrackRedownloadService.resolveBestMatch(
item,
includeExtensions: includeExtensions,
),
shouldStop: () => cancelled || !context.mounted || !isActive(),
onProgress: (index, item) =>
BatchProgressDialog.update(current: index + 1, detail: item.trackName),
);
final matchedTracks = matches.tracks;
final skippedCount = matches.skipped;
if (!context.mounted || !isActive()) {
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)));
onComplete();
}
Future<void> reEnrichLocalTracks(
BuildContext context,
WidgetRef ref,
List<LocalLibraryItem> selected, {
required bool Function() isActive,
required Future<void> Function() onSelectionHide,
required VoidCallback onSelectionRestore,
required VoidCallback onComplete,
}) async {
if (selected.isEmpty) return;
// Capture a stable route context before a caller removes its overlay.
context = Navigator.of(context, rootNavigator: true).context;
await onSelectionHide();
if (!context.mounted || !isActive()) return;
final selection = await showReEnrichFieldDialog(
context,
selectedCount: selected.length,
);
if (selection == null || !context.mounted || !isActive()) {
// Cancelled — restore selection mode (IDs are still intact).
if (context.mounted && isActive()) onSelectionRestore();
return;
}
final runner = BatchReEnrichRunner(
beginPhase: () async {
final settings = ref.read(settingsProvider);
await ref
.read(settingsProvider.notifier)
.syncLyricsSettingsToBackend(settings: settings);
return settings;
},
);
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: selection.usesManualValues
? context.l10n.trackReEnrichPreparing
: context.l10n.trackReEnrichSearching,
total: selected.length,
icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
final previews = await runner.preview(
selected,
selection,
shouldStop: () => cancelled || !context.mounted || !isActive(),
onProgress: (index, item) => BatchProgressDialog.update(
current: index + 1,
detail: '${item.trackName} - ${item.artistName}',
),
);
if (!context.mounted || !isActive()) return;
if (!cancelled) BatchProgressDialog.dismiss(context);
if (cancelled) {
onSelectionRestore();
return;
}
if (previews.isEmpty) {
onSelectionRestore();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)),
);
return;
}
final confirmed = await showReEnrichReviewSheet(context, previews: previews);
if (!confirmed || !context.mounted || !isActive()) {
if (context.mounted && isActive()) onSelectionRestore();
return;
}
final total = previews.length;
cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackReEnrichProgress,
total: total,
icon: Icons.auto_fix_high,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
final successCount = await runner.apply(
previews,
shouldStop: () => cancelled || !context.mounted || !isActive(),
onProgress: (index, item) => BatchProgressDialog.update(
current: index + 1,
detail: '${item.trackName} - ${item.artistName}',
),
);
if (!context.mounted || !isActive()) return;
if (!cancelled) BatchProgressDialog.dismiss(context);
try {
if (!ref.read(localLibraryProvider).isScanning) {
await ref.read(localLibraryProvider.notifier).scanAllSources();
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
} catch (_) {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
if (!context.mounted || !isActive()) return;
onComplete();
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)));
}
/// Runs a batch against one settings snapshot per phase. Dependencies are
/// injectable so cancellation, dispatch, and partial failure can be verified
/// without writing media files or opening dialogs.
class BatchReEnrichRunner {
final Future<AppSettings> Function() beginPhase;
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) reEnrich;
final Future<void> Function({
required String audioFilePath,
required Map<String, dynamic> reEnrichResult,
})
writeSidecar;
final Future<bool> Function({
required LocalLibraryItem item,
required Map<String, dynamic> result,
required String artistTagMode,
})
applyFfmpeg;
BatchReEnrichRunner({
required this.beginPhase,
this.reEnrich = PlatformBridge.reEnrichFile,
this.writeSidecar = writeReEnrichSidecarLrc,
this.applyFfmpeg = applyFfmpegReEnrichResult,
});
Future<List<BatchReEnrichPreview>> preview(
List<LocalLibraryItem> items,
ReEnrichFieldSelection selection, {
required bool Function() shouldStop,
required void Function(int, LocalLibraryItem) onProgress,
}) async {
if (shouldStop()) return [];
final settings = await beginPhase();
final previews = <BatchReEnrichPreview>[];
for (var index = 0; index < items.length; index++) {
if (shouldStop()) break;
final item = items[index];
onProgress(index, item);
final fields = selection.updateFieldsFor(item);
if (fields.isEmpty) continue;
if (selection.usesManualValues) {
final preview = buildManualBatchReEnrichPreview(item, selection);
if (preview != null) previews.add(preview);
continue;
}
try {
final result = await reEnrich(
buildBatchReEnrichRequest(
item: item,
settings: settings,
updateFields: fields,
previewOnly: true,
),
);
final rawMetadata = result['enriched_metadata'];
if (result['method'] != 'preview' || rawMetadata is! Map) continue;
final metadata = rawMetadata.map(
(key, value) => MapEntry(key.toString(), value),
);
final changes = buildReEnrichMetadataChanges(item, metadata, fields);
if (changes.isEmpty) continue;
previews.add(
BatchReEnrichPreview(
item: item,
updateFields: fields,
enrichedMetadata: metadata,
changes: changes,
),
);
} catch (_) {
// A failed lookup must not prevent review of other tracks.
}
}
return previews;
}
Future<int> apply(
List<BatchReEnrichPreview> previews, {
required bool Function() shouldStop,
required void Function(int, LocalLibraryItem) onProgress,
}) async {
if (shouldStop()) return 0;
// Settings may have changed while the review sheet was open.
final settings = await beginPhase();
var successes = 0;
for (var index = 0; index < previews.length; index++) {
if (shouldStop()) break;
final preview = previews[index];
onProgress(index, preview.item);
try {
final result = await reEnrich(
buildBatchReEnrichRequest(
item: preview.item,
settings: settings,
updateFields: preview.updateFields,
resolvedMetadata: preview.enrichedMetadata,
),
);
switch (result['method']) {
case 'native':
await writeSidecar(
audioFilePath: preview.item.filePath,
reEnrichResult: result,
);
successes++;
case 'ffmpeg':
if (await applyFfmpeg(
item: preview.item,
result: result,
artistTagMode: settings.artistTagMode,
)) {
successes++;
}
}
} catch (_) {
// Keep successful files and continue with the rest of the selection.
}
}
return successes;
}
}
/// Cancellation stops further matching and retains reliable matches already
/// resolved, matching the queue action's existing partial-completion behavior.
Future<({List<Track> tracks, int skipped})> matchLocalTracksForFlac(
List<LocalLibraryItem> items, {
required Future<LocalTrackRedownloadResolution> Function(LocalLibraryItem)
resolve,
required bool Function() shouldStop,
required void Function(int, LocalLibraryItem) onProgress,
}) async {
final tracks = <Track>[];
var skipped = 0;
for (var index = 0; index < items.length; index++) {
if (shouldStop()) break;
onProgress(index, items[index]);
try {
final resolution = await resolve(items[index]);
if (resolution.canQueue && resolution.match != null) {
tracks.add(resolution.match!);
} else {
skipped++;
}
} catch (_) {
skipped++;
}
}
return (tracks: tracks, skipped: skipped);
}