mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 22:26:52 +02:00
511 lines
16 KiB
Dart
511 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/utils/re_enrich_result.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';
|
|
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/downloaded_embedded_cover_resolver.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/int_utils.dart';
|
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.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 showAppDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AppAlertDialog(
|
|
title: Text(context.l10n.queueFlacAction),
|
|
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
|
|
actions: [
|
|
AppDialogAction(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
AppDialogAction(
|
|
filled: true,
|
|
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> reEnrichLibraryTracks(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
List<UnifiedLibraryItem> selected, {
|
|
required bool Function() isActive,
|
|
required Future<void> Function() onSelectionHide,
|
|
required VoidCallback onSelectionRestore,
|
|
required VoidCallback onComplete,
|
|
}) async {
|
|
if (selected.isEmpty) return;
|
|
final downloads = selected
|
|
.map((item) => item.historyItem)
|
|
.whereType<DownloadHistoryItem>()
|
|
.toList(growable: false);
|
|
final history = downloads.isEmpty
|
|
? null
|
|
: ref.read(downloadHistoryProvider.notifier);
|
|
await reEnrichLocalTracks(
|
|
context,
|
|
ref,
|
|
[
|
|
for (final item in selected)
|
|
if (item.localItem != null)
|
|
item.localItem!
|
|
else if (item.historyItem case final track?)
|
|
LocalLibraryItem.fromJson({
|
|
...track.toJson(),
|
|
'scannedAt': track.downloadedAt.toIso8601String(),
|
|
}),
|
|
],
|
|
isActive: isActive,
|
|
onSelectionHide: onSelectionHide,
|
|
onSelectionRestore: onSelectionRestore,
|
|
onComplete: onComplete,
|
|
refreshLibrary: () async {
|
|
// Downloaded tracks read history, not the local-scan index. Read back
|
|
// saved tags so failed or unselected fields cannot change history.
|
|
for (final track in downloads) {
|
|
try {
|
|
await DownloadedEmbeddedCoverResolver.invalidate(track.filePath);
|
|
final metadata = await PlatformBridge.readDisplayAudioMetadata(
|
|
track.filePath,
|
|
);
|
|
if (metadata['error'] != null) continue;
|
|
await history!.updateMetadataForItem(
|
|
id: track.id,
|
|
trackName: metadata['title'] as String? ?? track.trackName,
|
|
artistName: metadata['artist'] as String? ?? track.artistName,
|
|
albumName: metadata['album'] as String? ?? track.albumName,
|
|
albumArtist: metadata['album_artist'] as String?,
|
|
isrc: metadata['isrc'] as String?,
|
|
trackNumber: readPositiveInt(metadata['track_number']),
|
|
totalTracks: readPositiveInt(metadata['total_tracks']),
|
|
discNumber: readPositiveInt(metadata['disc_number']),
|
|
totalDiscs: readPositiveInt(metadata['total_discs']),
|
|
releaseDate: metadata['date'] as String?,
|
|
genre: metadata['genre'] as String?,
|
|
composer: metadata['composer'] as String?,
|
|
label: metadata['label'] as String?,
|
|
copyright: metadata['copyright'] as String?,
|
|
explicit: parseExplicitFlag(metadata['explicit']),
|
|
hasLyrics:
|
|
metadata.containsKey('hasLyrics') ||
|
|
metadata.containsKey('lyrics')
|
|
? metadata['hasLyrics'] == true ||
|
|
hasUsableLyricsContent(
|
|
metadata['lyrics']?.toString() ?? '',
|
|
)
|
|
: null,
|
|
);
|
|
} catch (error) {
|
|
debugPrint('Could not refresh re-enriched download: $error');
|
|
}
|
|
}
|
|
if (isActive() && selected.any((item) => item.localItem != null)) {
|
|
await _refreshReEnrichedLocalLibrary(ref);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshReEnrichedLocalLibrary(WidgetRef ref) async {
|
|
final library = ref.read(localLibraryProvider.notifier);
|
|
try {
|
|
if (!ref.read(localLibraryProvider).isScanning) {
|
|
await library.scanAllSources();
|
|
} else {
|
|
await library.reloadFromStorage();
|
|
}
|
|
} catch (_) {
|
|
await library.reloadFromStorage();
|
|
}
|
|
}
|
|
|
|
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,
|
|
Future<void> Function()? refreshLibrary,
|
|
}) 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 lyricsSummary = ReEnrichLyricsSummary();
|
|
final successCount = await runner.apply(
|
|
previews,
|
|
onResult: lyricsSummary.add,
|
|
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 (refreshLibrary != null) {
|
|
await refreshLibrary();
|
|
} else {
|
|
await _refreshReEnrichedLocalLibrary(ref);
|
|
}
|
|
|
|
if (!context.mounted || !isActive()) return;
|
|
onComplete();
|
|
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
final summary = lyricsSummary.message(context.l10n, successCount, total);
|
|
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,
|
|
void Function(Map<String, dynamic>)? onResult,
|
|
}) 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++;
|
|
onResult?.call(result);
|
|
case 'ffmpeg':
|
|
if (await applyFfmpeg(
|
|
item: preview.item,
|
|
result: result,
|
|
artistTagMode: settings.artistTagMode,
|
|
)) {
|
|
successes++;
|
|
onResult?.call(result);
|
|
}
|
|
}
|
|
} 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);
|
|
}
|