refactor(layering): move UI action helpers from services and utils to controllers

Batch actions, clickable metadata, playback, auth launcher, action forms and
the glass warmup drive UI flows, so they belong with the controllers. Files
with more than three importers (or an importer that must stay untouched) keep
a one-line re-export at the old path.
This commit is contained in:
zarzet committed 2026-10-19 00:37:42 +07:00
1 parent 190801c398
commit ab8a329d2d
19 files changed
+2967 -2961

No files matched your search

+675
View File
@@ -0,0 +1,675 @@
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/theme/app_motion.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/conversion_library_service.dart';
import 'package:spotiflac_android/services/ffmpeg_service.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/theme/mornye_theme.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/utils/logger.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/utils/saf_display_path.dart';
final _batchActionsLog = AppLogger('BatchActions');
class _BatchConversionFailure implements Exception {
final String stage;
final String message;
const _BatchConversionFailure(this.stage, this.message);
@override
String toString() => '$stage: $message';
}
/// 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;
// The queue launches this action from a temporary OverlayEntry. Its
// onSheetOpen callback removes that entry, which deactivates [context]. Keep
// the root Navigator's context before doing so; it remains mounted for the
// sheet, the confirmation dialog, progress updates, and the final snackbar.
final modalContext = Navigator.of(context, rootNavigator: true).context;
// Resolve localized strings up front; the builder must not look up
// Localizations via a possibly deactivated context.
final sheetTitle = modalContext.l10n.selectionBatchConvertConfirmTitle;
final sheetConfirmLabel = modalContext.l10n.selectionConvertCount(
selectedItems.length,
);
onSheetOpen?.call();
await showModalBottomSheet<void>(
context: modalContext,
sheetAnimationStyle: AppMotion.surfaceEntrance(modalContext),
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: modalContext.isMornye ? Colors.transparent : null,
elevation: modalContext.isMornye ? 0 : null,
builder: (sheetContext) => BatchConvertSheet(
formats: formats,
title: sheetTitle,
confirmLabel: sheetConfirmLabel,
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
onConvert:
(format, bitrate, losslessQuality, losslessProcessing, keepOriginal) {
didStartConversion = true;
Navigator.pop(sheetContext);
_performBatchConversion(
modalContext,
ref,
selectedItems: selectedItems,
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
keepOriginal: keepOriginal,
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(),
bool keepOriginal = false,
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 showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
keepOriginal
? context.l10n.selectionBatchConvertConfirmKeepOriginal(
convertibleItems.length,
targetFormat,
)
: 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: [
AppDialogAction(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDefault: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.trackConvertFormat),
),
],
),
);
if (confirmed != true || !context.mounted) return;
int successCount = 0;
final failures = <String>[];
final total = convertibleItems.length;
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);
String failureStage = 'metadata';
String? coverPath;
String? safTempPath;
String? convertedSafTempPath;
try {
final metadata = <String, String>{
'TITLE': item.trackName,
'ARTIST': item.artistName,
'ALBUM': item.albumName,
if (item.historyItem?.explicit == true ||
item.localItem?.explicit == true)
'ITUNESADVISORY': '1',
};
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,
);
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);
if (isSaf) {
failureStage = 'read SAF source';
safTempPath = await ConversionLibraryService.copySafSourceToTemp(
item.filePath,
);
if (safTempPath == null || safTempPath.isEmpty) {
throw const _BatchConversionFailure(
'read SAF source',
'temporary copy was not created',
);
}
workingPath = safTempPath;
}
failureStage = 'FFmpeg conversion';
final newPath = await FFmpegService.convertAudioFormat(
inputPath: workingPath,
targetFormat: targetFormat.toLowerCase(),
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
artistTagMode: settings.artistTagMode,
deleteOriginal: !isSaf && !keepOriginal,
sourceBitDepth: item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (newPath == null) {
throw const _BatchConversionFailure(
'FFmpeg conversion',
'converter returned no output',
);
}
if (isSaf) convertedSafTempPath = newPath;
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) {
failureStage = 'publish SAF output';
final hi = item.historyItem!;
final treeUri = hi.downloadTreeUri;
final relativeDir = hi.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
final oldFileName = hi.safFileName ?? '';
final published = await ConversionLibraryService.publishSafConversion(
treeUri: treeUri,
relativeDir: relativeDir,
originalFileName: oldFileName,
targetFormat: targetFormat,
sourcePath: newPath,
keepOriginal: keepOriginal,
);
final safUri = published?.uri;
if (safUri == null || safUri.isEmpty) {
throw const _BatchConversionFailure(
'publish SAF output',
'destination file was not created',
);
}
if (!keepOriginal && !isSameContentUri(item.filePath, safUri)) {
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
}
await ConversionLibraryService.persistHistoryConversion(
source: hi,
newFilePath: safUri,
newQuality: newQuality,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
keepOriginal: keepOriginal,
newSafFileName: published!.fileName,
);
} else {
throw const _BatchConversionFailure(
'publish SAF output',
'download folder permission is unavailable',
);
}
try {
await File(newPath).delete();
} catch (_) {}
if (safTempPath != null) {
try {
await File(safTempPath).delete();
} catch (_) {}
}
} else if (isSaf && item.localItem != null) {
failureStage = 'publish SAF output';
final location = resolveSafDocumentLocation(item.filePath);
if (location != null) {
final published = await ConversionLibraryService.publishSafConversion(
treeUri: location.treeUri,
relativeDir: location.relativeDir,
originalFileName: location.fileName,
targetFormat: targetFormat,
sourcePath: newPath,
keepOriginal: keepOriginal,
);
final safUri = published?.uri;
if (safUri == null || safUri.isEmpty) {
throw const _BatchConversionFailure(
'publish SAF output',
'destination file was not created',
);
}
if (!keepOriginal && !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,
keepOriginal: keepOriginal,
);
} else {
throw const _BatchConversionFailure(
'publish SAF output',
'source document location could not be resolved',
);
}
try {
await File(newPath).delete();
} catch (_) {}
if (safTempPath != null) {
try {
await File(safTempPath).delete();
} catch (_) {}
}
} else if (item.historyItem != null) {
await ConversionLibraryService.persistHistoryConversion(
source: item.historyItem!,
newFilePath: newPath,
newQuality: newQuality,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
keepOriginal: keepOriginal,
);
} else if (item.localItem != null) {
await LibraryDatabase.instance.replaceWithConvertedItem(
item: item.localItem!,
newFilePath: newPath,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
keepOriginal: keepOriginal,
);
}
successCount++;
} catch (error, stackTrace) {
final detail = error is _BatchConversionFailure
? error.toString()
: '$failureStage: $error';
failures.add('${item.trackName} — $detail');
_batchActionsLog.e(
'Batch conversion failed for ${item.trackName} at $failureStage',
error,
stackTrace,
);
} finally {
for (final path in <String?>[
coverPath,
safTempPath,
convertedSafTempPath,
]) {
if (path == null || path.isEmpty) continue;
try {
final file = File(path);
if (await file.exists()) await file.delete();
} catch (error) {
_batchActionsLog.w('Failed to clean temporary file $path: $error');
}
}
}
}
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
await 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)}'
'${failures.isEmpty ? '' : ' • ${context.l10n.trackConvertFailed}: ${failures.length} (${failures.first})'}',
),
),
);
}
}
/// Adds or removes ReplayGain tags for [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 sourceContext,
List<UnifiedLibraryItem> selectedItems, {
required VoidCallback onExitSelectionMode,
bool remove = false,
VoidCallback? onConfirmOpen,
Future<void> Function(bool confirmed)? onConfirmClosed,
}) async {
if (selectedItems.isEmpty) return;
final container = ProviderScope.containerOf(sourceContext, listen: false);
// The selection overlay can disappear when opening the confirmation dialog.
final context = Navigator.of(sourceContext, rootNavigator: true).context;
onConfirmOpen?.call();
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(
remove
? ctx.l10n.trackRemoveReplayGain
: ctx.l10n.replayGainBatchConfirmTitle,
),
content: Text(
remove
? ctx.l10n.replayGainRemoveConfirmMessage(selectedItems.length)
: ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length),
),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDefault: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(
remove
? ctx.l10n.trackRemoveReplayGain
: ctx.l10n.replayGainBatchConfirmTitle,
),
),
],
),
);
await onConfirmClosed?.call(confirmed == true);
if (confirmed != true || !context.mounted) return;
var cancelled = false;
int successCount = 0;
var unsupportedDecoder = false;
final total = selectedItems.length;
BatchProgressDialog.show(
context: context,
title: remove
? context.l10n.replayGainRemoving
: 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 = remove
? await ReplayGainService.removeFromFile(item.filePath)
: await ReplayGainService.applyToFile(
item.filePath,
onUnsupportedDecoder: () => unsupportedDecoder = true,
);
if (ok) {
successCount++;
try {
if (item.historyItem case final history?) {
await container
.read(downloadHistoryProvider.notifier)
.updateAudioMetadataForItem(
id: history.id,
hasReplayGain: !remove,
replayGainMetadataScanVersion: 1,
);
}
if (item.localItem case final local?) {
await LibraryDatabase.instance.updateAudioMetadata(
local.id,
hasReplayGain: !remove,
);
}
} catch (e) {
_batchActionsLog.w('Could not refresh ReplayGain availability: $e');
}
}
} catch (_) {}
}
onExitSelectionMode();
if (successCount > 0 && context.mounted) {
await container.read(localLibraryProvider.notifier).reloadFromStorage();
}
if (!context.mounted) return;
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
[
remove
? context.l10n.replayGainRemoveBatchSuccess(successCount, total)
: context.l10n.replayGainBatchSuccess(successCount, total),
if (unsupportedDecoder) context.l10n.replayGainUnsupportedDecoder,
].join('\n'),
),
),
);
}
+892
View File
@@ -0,0 +1,892 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.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/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/extension_collection_screens.dart'
show ExtensionArtistScreen, ExtensionAlbumScreen;
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
final _log = AppLogger('ClickableMetadata');
class _MetadataSearchResult {
final String providerId;
final List<Map<String, dynamic>> items;
const _MetadataSearchResult({required this.providerId, required this.items});
}
Future<_MetadataSearchResult?> _searchMetadataProviders(
BuildContext context,
String query, {
required String filter,
int limit = 5,
String? sourceProviderId,
}) async {
final providerIds = _metadataSearchProviderCandidates(
context,
sourceProviderId: sourceProviderId,
);
for (final providerId in providerIds) {
try {
final items = await _searchMetadataProvider(
providerId,
query,
filter: filter,
limit: limit,
);
if (items.isNotEmpty) {
return _MetadataSearchResult(providerId: providerId, items: items);
}
} catch (e) {
_log.w(
'Metadata lookup failed for provider "$providerId", filter=$filter: $e',
);
}
}
return null;
}
Future<List<Map<String, dynamic>>> _searchMetadataProvider(
String providerId,
String query, {
required String filter,
required int limit,
}) async {
return PlatformBridge.customSearchWithExtension(
providerId,
query,
options: {'filter': filter, 'limit': limit},
);
}
List<String> _metadataSearchProviderCandidates(
BuildContext context, {
String? sourceProviderId,
}) {
final container = ProviderScope.containerOf(context, listen: false);
final extensionState = container.read(extensionProvider);
final settings = container.read(settingsProvider);
final extensionNotifier = container.read(extensionProvider.notifier);
final candidates = <String>[];
void addProvider(String? providerId) {
final normalized = providerId?.trim();
if (normalized == null ||
normalized.isEmpty ||
candidates.contains(normalized) ||
!_canSearchMetadataProvider(normalized, extensionState)) {
return;
}
candidates.add(normalized);
}
addProvider(sourceProviderId);
addProvider(settings.searchProvider);
for (final providerId in extensionState.metadataProviderPriority) {
addProvider(providerId);
}
for (final providerId in extensionNotifier.getAllMetadataProviders()) {
addProvider(providerId);
}
final searchExtensions = extensionState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList(growable: false);
for (final extension in searchExtensions.where(
(ext) => ext.searchBehavior?.primary == true,
)) {
addProvider(extension.id);
}
for (final extension in searchExtensions.where(
(ext) => ext.searchBehavior?.primary != true,
)) {
addProvider(extension.id);
}
return candidates;
}
bool _canSearchMetadataProvider(
String providerId,
ExtensionState extensionState,
) {
return extensionState.extensions.any(
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == providerId,
);
}
Future<void> navigateToArtistCredits(
BuildContext context, {
required String artistNames,
String? artistIds,
String? extensionId,
}) async {
final artists = _buildArtistTapTargets(artistNames, artistIds);
if (artists.isEmpty) return;
final selected = artists.length == 1
? artists.single
: await showAppBottomSheet<_ArtistTapTarget>(
context: context,
useRootNavigator: true,
title: context.l10n.mornyeGoToArtist,
maxHeightFactor: 0.6,
builder: (context) => ListView(
shrinkWrap: true,
children: [
for (final artist in artists)
AppSheetOption(
leading: const Icon(Icons.person_outline),
title: Text(artist.name),
onTap: () => Navigator.pop(context, artist),
),
],
),
);
if (!context.mounted || selected == null) return;
await navigateToArtist(
context,
artistName: selected.name,
artistId: selected.artistId,
extensionId: extensionId,
);
}
Future<void> navigateToArtist(
BuildContext context, {
required String artistName,
String? artistId,
String? coverUrl,
String? extensionId,
}) async {
if (artistName.isEmpty) return;
final normalizedArtistId = _normalizeArtistId(artistId);
if (normalizedArtistId != null &&
_canNavigateArtistDirectly(
artistId: normalizedArtistId,
extensionId: extensionId,
)) {
_pushArtistScreen(
context,
artistId: normalizedArtistId,
artistName: artistName,
coverUrl: coverUrl,
extensionId: extensionId,
);
return;
}
_showLoadingSnackBar(context, context.l10n.clickableLookingUpArtist);
try {
final searchResult = await _searchMetadataProviders(
context,
artistName,
filter: 'artist',
limit: 20,
sourceProviderId: extensionId,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
final artistList = searchResult?.items ?? const <Map<String, dynamic>>[];
if (artistList.isEmpty) {
_showUnavailable(context, context.l10n.trackArtist);
return;
}
final bestMatch = _pickBestResultByName(artistList, artistName);
if (bestMatch == null) {
_showUnavailable(context, context.l10n.trackArtist);
return;
}
final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? artistName;
final resolvedImage = bestMatch['images'] as String?;
final resolvedProviderId = _resolveResultProviderId(
bestMatch,
searchResult?.providerId,
);
if (resolvedId.isEmpty) {
_showUnavailable(context, context.l10n.trackArtist);
return;
}
if (!context.mounted) return;
_pushArtistScreen(
context,
artistId: resolvedId,
artistName: resolvedName,
coverUrl: resolvedImage ?? coverUrl,
extensionId: resolvedProviderId,
);
} catch (e) {
_log.e('Failed to look up artist "$artistName": $e', e);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
_showUnavailable(context, context.l10n.trackArtist);
}
}
Future<void> navigateToAlbum(
BuildContext context, {
required String albumName,
String? albumId,
String? artistName,
String? coverUrl,
String? extensionId,
}) async {
if (albumName.isEmpty) return;
if (albumId != null && albumId.isNotEmpty && !_isUnknownResourceId(albumId)) {
_pushAlbumScreen(
context,
albumId: albumId,
albumName: albumName,
coverUrl: coverUrl,
extensionId: extensionId,
);
return;
}
_showLoadingSnackBar(context, 'Looking up album...');
try {
final query = artistName != null && artistName.isNotEmpty
? '$albumName $artistName'
: albumName;
final searchResult = await _searchMetadataProviders(
context,
query,
filter: 'album',
limit: 20,
sourceProviderId: extensionId,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
final albumList = searchResult?.items ?? const <Map<String, dynamic>>[];
if (albumList.isEmpty) {
_showUnavailable(context, 'Album');
return;
}
final bestMatch = _pickBestResultByName(albumList, albumName);
if (bestMatch == null) {
_showUnavailable(context, 'Album');
return;
}
final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? albumName;
final resolvedImage = bestMatch['images'] as String?;
final resolvedProviderId = _resolveResultProviderId(
bestMatch,
searchResult?.providerId,
);
if (resolvedId.isEmpty) {
_showUnavailable(context, 'Album');
return;
}
if (!context.mounted) return;
_pushAlbumScreen(
context,
albumId: resolvedId,
albumName: resolvedName,
coverUrl: resolvedImage ?? coverUrl,
extensionId: resolvedProviderId,
);
} catch (e) {
_log.e('Failed to look up album "$albumName": $e', e);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
_showUnavailable(context, 'Album');
}
}
/// Opens the album for a track, enriching sparse search results first when
/// they do not include album identity. This keeps collection actions generic:
/// any metadata extension can supply the missing track fields through search.
Future<void> navigateToTrackAlbum(BuildContext context, Track track) async {
final albumName = track.albumName.trim();
final albumId = track.albumId?.trim();
final hasAlbumId = albumId != null && !_isUnknownResourceId(albumId);
if (albumName.isNotEmpty || hasAlbumId) {
await navigateToAlbum(
context,
albumName: albumName.isNotEmpty ? albumName : context.l10n.trackAlbum,
albumId: hasAlbumId ? albumId : null,
artistName: _preferredAlbumArtist(track),
coverUrl: track.coverUrl,
extensionId: track.source,
);
return;
}
final query = [
track.name.trim(),
track.artistName.trim(),
].where((part) => part.isNotEmpty).join(' ');
if (query.isEmpty) {
_showUnavailable(context, context.l10n.trackAlbum);
return;
}
_showLoadingSnackBar(context, 'Looking up album...');
try {
final searchResult = await _searchMetadataProviders(
context,
query,
filter: 'track',
limit: 20,
sourceProviderId: track.source,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
final matches = searchResult?.items ?? const <Map<String, dynamic>>[];
final bestMatch = _pickBestResultByName(matches, track.name);
if (bestMatch == null) {
_showUnavailable(context, context.l10n.trackAlbum);
return;
}
final providerId = _resolveResultProviderId(
bestMatch,
searchResult?.providerId,
);
final resolvedTrack = Track.fromBackendMap(bestMatch, source: providerId);
final resolvedAlbumName = resolvedTrack.albumName.trim();
final resolvedAlbumId = resolvedTrack.albumId?.trim();
final hasResolvedAlbumId =
resolvedAlbumId != null && !_isUnknownResourceId(resolvedAlbumId);
if (resolvedAlbumName.isEmpty && !hasResolvedAlbumId) {
_showUnavailable(context, context.l10n.trackAlbum);
return;
}
await navigateToAlbum(
context,
albumName: resolvedAlbumName.isNotEmpty
? resolvedAlbumName
: context.l10n.trackAlbum,
albumId: hasResolvedAlbumId ? resolvedAlbumId : null,
artistName: _preferredAlbumArtist(resolvedTrack),
coverUrl: resolvedTrack.coverUrl ?? track.coverUrl,
extensionId: providerId,
);
} catch (e) {
_log.e('Failed to resolve album for track "${track.name}": $e', e);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
_showUnavailable(context, context.l10n.trackAlbum);
}
}
String _preferredAlbumArtist(Track track) {
final albumArtist = (track.albumArtist ?? '').trim();
return albumArtist.isNotEmpty ? albumArtist : track.artistName;
}
void _pushArtistScreen(
BuildContext context, {
required String artistId,
required String artistName,
String? coverUrl,
String? extensionId,
}) {
final isExtension = extensionId != null;
final resolvedProviderId = extensionId;
pushViaPreferredNavigator(
context,
(context) => isExtension && resolvedProviderId != null
? ExtensionArtistScreen(
extensionId: resolvedProviderId,
artistId: artistId,
artistName: artistName,
coverUrl: coverUrl,
)
: ArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: coverUrl,
extensionId: resolvedProviderId,
),
);
}
void _pushAlbumScreen(
BuildContext context, {
required String albumId,
required String albumName,
String? coverUrl,
String? extensionId,
}) {
final isExtension = extensionId != null;
final resolvedExtensionId = extensionId;
pushViaPreferredNavigator(
context,
(context) => isExtension && resolvedExtensionId != null
? ExtensionAlbumScreen(
extensionId: resolvedExtensionId,
albumId: albumId,
albumName: albumName,
coverUrl: coverUrl,
)
: AlbumScreen(
albumId: albumId,
albumName: albumName,
coverUrl: coverUrl,
extensionId: resolvedExtensionId,
tracks: const [],
),
);
}
/// Pushes detail pages into the active tab navigator when invoked from a root
/// modal such as Now Playing. The root modal is dismissed first so the album
/// is immediately visible instead of being hidden behind it.
void pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
final currentNavigator = Navigator.of(context);
final rootNavigator = Navigator.of(context, rootNavigator: true);
final activeTabNavigator = ShellNavigationService.activeTabNavigator();
final shouldRouteToTabNavigator =
identical(currentNavigator, rootNavigator) && activeTabNavigator != null;
if (!shouldRouteToTabNavigator) {
currentNavigator.push(MaterialPageRoute<void>(builder: builder));
return;
}
final currentRoute = ModalRoute.of(context);
final shouldPopCurrentRoute =
currentRoute != null && currentRoute.isFirst == false;
if (shouldPopCurrentRoute && currentNavigator.canPop()) {
currentNavigator.pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!activeTabNavigator.mounted) return;
activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
});
return;
}
activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
}
void _showLoadingSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const AppLoadingIndicator(size: 16, strokeWidth: 2),
const SizedBox(width: 12),
Text(message),
],
),
duration: const Duration(seconds: 10),
),
);
}
void _showUnavailable(BuildContext context, String type) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.clickableInformationUnavailable(type))),
);
}
class ClickableArtistName extends StatefulWidget {
final String artistName;
final String? artistId;
final String? coverUrl;
final String? extensionId;
final TextStyle? style;
final int? maxLines;
final TextOverflow? overflow;
final TextAlign? textAlign;
const ClickableArtistName({
super.key,
required this.artistName,
this.artistId,
this.coverUrl,
this.extensionId,
this.style,
this.maxLines,
this.overflow,
this.textAlign,
});
@override
State<ClickableArtistName> createState() => _ClickableArtistNameState();
}
class _ClickableArtistNameState extends State<ClickableArtistName> {
List<_ArtistTapTarget> _artistTargets = const [];
final List<TapGestureRecognizer> _recognizers = [];
@override
void initState() {
super.initState();
_rebuildArtistTargets();
}
@override
void didUpdateWidget(covariant ClickableArtistName oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.artistName != widget.artistName ||
oldWidget.artistId != widget.artistId ||
oldWidget.coverUrl != widget.coverUrl ||
oldWidget.extensionId != widget.extensionId) {
_rebuildArtistTargets();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
void _rebuildArtistTargets() {
_disposeRecognizers();
_artistTargets = _buildArtistTapTargets(widget.artistName, widget.artistId);
if (_artistTargets.length <= 1) return;
for (final target in _artistTargets) {
final recognizer = TapGestureRecognizer()
..onTap = () => navigateToArtist(
context,
artistName: target.name,
artistId: target.artistId,
coverUrl: widget.coverUrl,
extensionId: _extensionIdForTarget(target),
);
_recognizers.add(recognizer);
}
}
String? _extensionIdForTarget(_ArtistTapTarget target) {
if (widget.extensionId == null) return null;
if (_artistTargets.length == 1) return widget.extensionId;
return target.artistId != null ? widget.extensionId : null;
}
List<InlineSpan> _buildMultiArtistSpans() {
final spans = <InlineSpan>[];
for (var i = 0; i < _artistTargets.length; i++) {
final target = _artistTargets[i];
spans.add(
TextSpan(
text: target.name,
style: widget.style,
recognizer: _recognizers[i],
),
);
if (i < _artistTargets.length - 1) {
spans.add(TextSpan(text: ', ', style: widget.style));
}
}
return spans;
}
@override
Widget build(BuildContext context) {
if (_artistTargets.isEmpty) {
return Text(
widget.artistName,
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
textAlign: widget.textAlign,
);
}
if (_artistTargets.length == 1) {
final target = _artistTargets.first;
return GestureDetector(
onTap: () => navigateToArtist(
context,
artistName: target.name,
artistId: target.artistId,
coverUrl: widget.coverUrl,
extensionId: _extensionIdForTarget(target),
),
child: Text(
target.name,
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
textAlign: widget.textAlign,
),
);
}
return Text.rich(
TextSpan(style: widget.style, children: _buildMultiArtistSpans()),
maxLines: widget.maxLines,
overflow: widget.overflow ?? TextOverflow.clip,
textAlign: widget.textAlign ?? TextAlign.start,
);
}
}
class _ArtistTapTarget {
final String name;
final String? artistId;
const _ArtistTapTarget({required this.name, this.artistId});
}
List<_ArtistTapTarget> _buildArtistTapTargets(
String rawArtistNames,
String? rawArtistIds,
) {
final parsedIds = _parseArtistIds(rawArtistIds);
final parsedNames = splitArtistNames(
rawArtistNames,
creditedArtistCount: parsedIds.length,
);
if (parsedNames.isEmpty) return const [];
final uniqueNames = <String>[];
final seen = <String>{};
for (final parsed in parsedNames) {
final key = parsed.toLowerCase().replaceAll(RegExp(r'\s+'), ' ').trim();
if (key.isEmpty || !seen.add(key)) continue;
uniqueNames.add(parsed);
}
if (uniqueNames.isEmpty) return const [];
if (uniqueNames.length == 1) {
return [
_ArtistTapTarget(
name: uniqueNames.first,
artistId: _normalizeArtistId(rawArtistIds),
),
];
}
if (parsedIds.isEmpty || !parsedIds.any((id) => id != null)) {
return uniqueNames
.map((name) => _ArtistTapTarget(name: name))
.toList(growable: false);
}
// Providers may return one id per artist (aligned with the names) or only
// the primary artist's id. Map ids to names positionally, preserving empty
// slots as null, so each tapped artist navigates by its own id when known.
return List<_ArtistTapTarget>.generate(
uniqueNames.length,
(index) => _ArtistTapTarget(
name: uniqueNames[index],
artistId: index < parsedIds.length ? parsedIds[index] : null,
),
growable: false,
);
}
List<String?> _parseArtistIds(String? rawArtistIds) {
final raw = rawArtistIds?.trim();
if (raw == null || raw.isEmpty) return const [];
return raw
.split(RegExp(r'\s*,\s*'))
.map(_normalizeArtistId)
.toList(growable: false);
}
String? _normalizeArtistId(String? artistId) {
final id = artistId?.trim();
if (id == null || _isUnknownResourceId(id)) {
return null;
}
return id;
}
bool _isUnknownResourceId(String id) {
final normalized = id.trim().toLowerCase();
return normalized.isEmpty ||
normalized == 'unknown' ||
normalized.endsWith(':unknown');
}
String? _resolveResultProviderId(
Map<String, dynamic> result,
String? fallbackProviderId,
) {
final providerId = result['provider_id']?.toString().trim();
if (providerId != null && providerId.isNotEmpty) return providerId;
final source = result['source']?.toString().trim();
if (source != null && source.isNotEmpty) return source;
final fallback = fallbackProviderId?.trim();
return fallback != null && fallback.isNotEmpty ? fallback : null;
}
bool _canNavigateArtistDirectly({
required String artistId,
required String? extensionId,
}) {
if (extensionId != null) return true;
return _spotifyArtistIdPattern.hasMatch(artistId);
}
/// Selects the result whose name best matches [query] instead of blindly
/// trusting the provider's first result. This prevents tapping an artist like
/// "creo" from opening a completely unrelated artist (e.g. "Tyler, the
/// Creator") just because the provider ranked it first.
Map<String, dynamic>? _pickBestResultByName(
List<Map<String, dynamic>> results,
String query,
) {
if (results.isEmpty) return null;
final normalizedQuery = _normalizeForMatch(query);
if (normalizedQuery.isEmpty) return results.first;
Map<String, dynamic>? best;
double bestScore = -1;
for (final result in results) {
final name = result['name'] as String? ?? '';
final normalizedName = _normalizeForMatch(name);
if (normalizedName.isEmpty) continue;
if (normalizedName == normalizedQuery) {
return result;
}
final score = _nameMatchScore(normalizedQuery, normalizedName);
if (score > bestScore) {
bestScore = score;
best = result;
}
}
// Accept a fuzzy match only when it is reasonably close. Otherwise fall back
// to the provider's own top result so we never crash on an empty match.
if (best != null && bestScore >= 0.5) {
if (bestScore < 0.85) {
_log.w(
'No exact match for "$query"; using closest result '
'"${best['name']}" (score ${bestScore.toStringAsFixed(2)})',
);
}
return best;
}
_log.w(
'No close match for "$query" among ${results.length} results; '
'falling back to first result "${results.first['name']}"',
);
return results.first;
}
String _normalizeForMatch(String value) {
return value
.toLowerCase()
.replaceAll(RegExp(r'[^\p{L}\p{N}\s]', unicode: true), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
/// Returns a similarity score in [0, 1] between two normalized names.
double _nameMatchScore(String query, String candidate) {
if (query == candidate) return 1.0;
final queryTokens = query.split(' ').where((t) => t.isNotEmpty).toSet();
final candidateTokens = candidate
.split(' ')
.where((t) => t.isNotEmpty)
.toSet();
if (queryTokens.isEmpty || candidateTokens.isEmpty) return 0;
final intersection = queryTokens.intersection(candidateTokens).length;
final union = queryTokens.union(candidateTokens).length;
final jaccard = union == 0 ? 0.0 : intersection / union;
// Reward full substring containment of the (shorter) query in the candidate.
double containment = 0;
if (candidate.contains(query) || query.contains(candidate)) {
final shorter = query.length < candidate.length ? query : candidate;
final longer = query.length < candidate.length ? candidate : query;
containment = longer.isEmpty ? 0 : shorter.length / longer.length;
}
return jaccard > containment ? jaccard : containment;
}
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
class ClickableAlbumName extends StatelessWidget {
final String albumName;
final String? albumId;
final String? artistName;
final String? coverUrl;
final String? extensionId;
final TextStyle? style;
final int? maxLines;
final TextOverflow? overflow;
final TextAlign? textAlign;
const ClickableAlbumName({
super.key,
required this.albumName,
this.albumId,
this.artistName,
this.coverUrl,
this.extensionId,
this.style,
this.maxLines,
this.overflow,
this.textAlign,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => navigateToAlbum(
context,
albumName: albumName,
albumId: albumId,
artistName: artistName,
coverUrl: coverUrl,
extensionId: extensionId,
),
child: Text(
albumName,
style: style,
maxLines: maxLines,
overflow: overflow,
textAlign: textAlign,
),
);
}
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
String trackDeletionResultMessage(
AppLocalizations l10n, {
required int deleted,
required int requested,
}) => [
if (deleted > 0 || requested == 0) l10n.snackbarDeletedTracks(deleted),
if (deleted < requested) l10n.snackbarDeleteTracksFailed(requested - deleted),
].join('\n');
/// Shows a delete-confirmation dialog, deletes the given [ids] one by one via
/// [deleteItem], then shows a "deleted N tracks" snackbar.
/// [onExitSelectionMode] runs after persistence if the screen is still open.
///
/// Returns the number of items [deleteItem] reported deleted, or null if the
/// user cancelled the dialog.
/// [persistDeletedItems] commits successfully deleted IDs once before the UI
/// reports completion, including partial success if a later deletion throws.
Future<int?> confirmAndDeleteTracks({
required BuildContext context,
required List<String> ids,
required Future<bool> Function(String id) deleteItem,
Future<void> Function(List<String> ids)? persistDeletedItems,
required VoidCallback onExitSelectionMode,
}) async {
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(ids.length)),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed != true || !context.mounted) return null;
final deletedIds = <String>[];
try {
for (final id in ids) {
if (await deleteItem(id)) deletedIds.add(id);
}
} finally {
if (deletedIds.isNotEmpty && persistDeletedItems != null) {
await persistDeletedItems(deletedIds);
}
}
final deletedCount = deletedIds.length;
if (context.mounted) {
onExitSelectionMode();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
trackDeletionResultMessage(
context.l10n,
deleted: deletedCount,
requested: ids.length,
),
),
),
);
}
return deletedCount;
}
@@ -0,0 +1,558 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/extension_session_callback.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:url_launcher/url_launcher.dart';
final _log = AppLogger('ExtensionAuthLauncher');
const extensionVerificationGrantTimeout = Duration(minutes: 3);
bool isExtensionVerificationRequired(Object error) {
final message = error.toString().toLowerCase();
return message.contains('verify_required') ||
message.contains('verification_required') ||
message.contains('verification required') ||
message.contains('needsverification') ||
message.contains('needs verification') ||
message.contains('session is not authenticated') ||
message.contains('signed session expired');
}
Future<T> runExtensionOperationWithVerificationRetry<T>({
required String extensionId,
required String browserMode,
required Future<T> Function() operation,
Future<bool> Function()? verify,
}) async {
try {
return await operation();
} catch (error) {
if (!isExtensionVerificationRequired(error)) rethrow;
final verified =
await (verify ??
() => openVerificationAndAwaitGrant(
extensionId,
browserMode: browserMode,
))();
if (!verified) rethrow;
return operation();
}
}
String? extensionIdFromVerificationError(
Object error,
Iterable<String> knownExtensionIds,
) {
final idsByNormalized = <String, String>{
for (final id in knownExtensionIds)
if (id.trim().isNotEmpty) id.trim().toLowerCase(): id.trim(),
};
if (idsByNormalized.isEmpty) return null;
final message = error.toString();
final canonicalMatch = RegExp(
r'''extension\s+['"]([^'"]+)['"]''',
caseSensitive: false,
).firstMatch(message);
final canonicalId = canonicalMatch?.group(1)?.trim().toLowerCase();
if (canonicalId != null && idsByNormalized.containsKey(canonicalId)) {
return idsByNormalized[canonicalId];
}
final lowerMessage = message.toLowerCase();
final orderedIds = idsByNormalized.keys.toList()
..sort((a, b) => b.length.compareTo(a.length));
for (final normalizedId in orderedIds) {
if (lowerMessage.contains(normalizedId)) {
return idsByNormalized[normalizedId];
}
}
return null;
}
Future<bool> openPendingExtensionVerification(
String extensionId, {
String browserMode = 'in_app_first',
void Function(Uri authUri)? onAuthUri,
Future<void>? cancellationSignal,
}) async {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return false;
var cancelled = false;
if (cancellationSignal != null) {
unawaited(cancellationSignal.then((_) => cancelled = true));
}
try {
final pending = await _awaitVerificationStepOrCancellation(
PlatformBridge.getExtensionPendingAuth(normalizedExtensionId),
cancellationSignal,
);
if (cancelled) return false;
final authUrl = pending?['auth_url']?.toString().trim() ?? '';
if (authUrl.isEmpty) {
_log.w('No pending verification challenge for $normalizedExtensionId');
showExtensionVerificationUnavailable(normalizedExtensionId);
return false;
}
final uri = Uri.tryParse(authUrl);
if (uri == null) return false;
onAuthUri?.call(uri);
final launched = await _awaitVerificationStepOrCancellation(
_launchVerificationUrl(
uri,
browserMode,
cancellationSignal: cancellationSignal,
),
cancellationSignal,
);
if (launched == null) return false;
if (launched) {
_log.i('Opened verification challenge for $normalizedExtensionId');
} else {
_log.w(
'Could not open verification challenge for $normalizedExtensionId',
);
return await showExtensionVerificationHelpDialog(
normalizedExtensionId,
uri,
browserMode: browserMode,
immediateFailure: true,
cancellationSignal: cancellationSignal,
);
}
return launched;
} catch (e) {
_log.w(
'Failed to open verification challenge for $normalizedExtensionId: $e',
);
if (!cancelled) showExtensionVerificationUnavailable(normalizedExtensionId);
return false;
}
}
void showExtensionVerificationUnavailable(String extensionId) {
final context = AppNavigationService.rootNavigatorKey.currentContext;
if (context == null || !context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(context.l10n.extensionVerificationUnavailable(extensionId)),
duration: const Duration(seconds: 8),
),
);
}
Timer? scheduleExtensionVerificationHelpDialog(
String extensionId,
Uri? authUri, {
String browserMode = 'in_app_first',
Duration delay = const Duration(seconds: 20),
Future<void>? cancellationSignal,
}) {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty || authUri == null) return null;
return Timer(delay, () {
unawaited(
showExtensionVerificationHelpDialog(
normalizedExtensionId,
authUri,
browserMode: browserMode,
cancellationSignal: cancellationSignal,
),
);
});
}
/// Opens a pending extension verification challenge and waits (up to 3
/// minutes, including pending lookup and browser launch) for its grant result,
/// returning whether it succeeded. When
/// [cancellationSignal] completes, all local waiting resources are released
/// and the method returns false without waiting for the timeout.
///
/// [awaitForeground], if given, is awaited first — passed the trimmed
/// [extensionId] — before opening the challenge; used by callers that must
/// defer verification until the app is foregrounded (launching a browser
/// from the background is blocked by the OS).
Future<bool> openVerificationAndAwaitGrant(
String extensionId, {
required String browserMode,
Future<void> Function(String extensionId)? awaitForeground,
Future<void>? cancellationSignal,
}) async {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return false;
if (awaitForeground != null) {
final reachedForeground = await _awaitVerificationStepOrCancellation(
awaitForeground(normalizedExtensionId).then((_) => true),
cancellationSignal,
);
if (reachedForeground != true) return false;
}
final grantCompleter = Completer<ExtensionSessionGrantEvent>();
late final StreamSubscription<ExtensionSessionGrantEvent> grantSub;
grantSub = PlatformBridge.extensionSessionGrantEvents()
.where((event) => event.extensionId.trim() == normalizedExtensionId)
.listen((event) {
if (!grantCompleter.isCompleted) {
grantCompleter.complete(event);
}
});
Uri? authUri;
Timer? helpDialogTimer;
final stopped = Completer<void>();
void stop() {
if (!stopped.isCompleted) stopped.complete();
}
if (cancellationSignal != null) {
unawaited(cancellationSignal.then((_) => stop()));
}
// The browser or manual-help dialog may never return. Bound the complete
// foreground flow, not only the grant wait reached after opening the page.
final deadline = Timer(extensionVerificationGrantTimeout, () {
_log.w('Timed out waiting for verification grant: $normalizedExtensionId');
stop();
});
try {
final opened = await _awaitVerificationStepOrCancellation(
openPendingExtensionVerification(
normalizedExtensionId,
browserMode: browserMode,
onAuthUri: (uri) => authUri = uri,
cancellationSignal: stopped.future,
),
stopped.future,
);
if (opened != true) return false;
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
normalizedExtensionId,
authUri,
browserMode: browserMode,
cancellationSignal: stopped.future,
);
final event = await _awaitVerificationStepOrCancellation(
grantCompleter.future,
stopped.future,
);
if (event == null) {
_log.i('Stopped waiting for verification grant: $normalizedExtensionId');
return false;
}
return event.success;
} on TimeoutException {
_log.w('Timed out waiting for verification grant: $normalizedExtensionId');
return false;
} finally {
deadline.cancel();
helpDialogTimer?.cancel();
stop();
await grantSub.cancel();
}
}
Future<T?> _awaitVerificationStepOrCancellation<T>(
Future<T> operation,
Future<void>? cancellationSignal,
) {
if (cancellationSignal == null) {
return operation.then<T?>((value) => value);
}
return Future.any<T?>([
operation.then<T?>((value) => value),
cancellationSignal.then<T?>((_) => null),
]);
}
Future<bool> showExtensionVerificationHelpDialog(
String extensionId,
Uri authUri, {
String browserMode = 'in_app_first',
bool immediateFailure = false,
Future<void>? cancellationSignal,
}) async {
final context = AppNavigationService.rootNavigatorKey.currentContext;
if (context == null) {
_log.w('Cannot show verification help dialog without root context');
return false;
}
final l10n = context.l10n;
final title = immediateFailure
? l10n.extensionVerificationHelpTitleManual
: l10n.extensionVerificationHelpTitleWaiting;
final message = immediateFailure
? l10n.extensionVerificationHelpMessageManual
: l10n.extensionVerificationHelpMessageWaiting;
final normalizedExtensionId = extensionId.trim();
BuildContext? activeDialogContext;
var closeRequested = false;
void closeDialog() {
closeRequested = true;
final dialogContext = activeDialogContext;
if (dialogContext == null || !dialogContext.mounted) return;
final route = ModalRoute.of(dialogContext);
if (route == null || !route.isActive) return;
final navigator = Navigator.of(dialogContext, rootNavigator: true);
if (route.isCurrent) {
navigator.pop();
} else {
// A newer dialog may be above this one; dismiss only our own route.
navigator.removeRoute(route);
}
}
if (cancellationSignal != null) {
unawaited(cancellationSignal.then((_) => closeDialog()));
}
var clipboardGrantInFlight = false;
late final StreamSubscription<ExtensionSessionGrantEvent> grantSub;
grantSub = PlatformBridge.extensionSessionGrantEvents()
.where(
(event) =>
event.success && event.extensionId.trim() == normalizedExtensionId,
)
.listen((_) {
_log.i(
'Closing verification help dialog after $normalizedExtensionId grant',
);
closeDialog();
});
try {
await showAppDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
activeDialogContext = dialogContext;
if (closeRequested) {
WidgetsBinding.instance.addPostFrameCallback((_) => closeDialog());
}
final dialogL10n = dialogContext.l10n;
return StatefulBuilder(
builder: (dialogContext, setDialogState) => AppAlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(message),
const SizedBox(height: 16),
DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(
dialogContext,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: SelectableText(
authUri.toString(),
maxLines: 4,
minLines: 1,
),
),
),
],
),
actions: [
ExpressiveButton(
text: true,
onPressed: clipboardGrantInFlight
? null
: () => Navigator.of(dialogContext).pop(),
child: Text(dialogL10n.extensionVerificationClose),
),
ExpressiveButton(
text: true,
icon: const Icon(Icons.copy),
onPressed: clipboardGrantInFlight
? null
: () {
Clipboard.setData(
ClipboardData(text: authUri.toString()),
);
ScaffoldMessenger.maybeOf(dialogContext)?.showSnackBar(
SnackBar(
content: Text(
dialogL10n.extensionVerificationLinkCopied,
),
),
);
},
child: Text(dialogL10n.extensionVerificationCopyLink),
),
ExpressiveButton(
text: true,
icon: clipboardGrantInFlight
? const AppLoadingIndicator(size: 18, strokeWidth: 2)
: const Icon(Icons.content_paste),
onPressed: clipboardGrantInFlight
? null
: () async {
setDialogState(() => clipboardGrantInFlight = true);
await _completeSessionGrantFromClipboard(
dialogContext,
extensionId,
authUri,
);
if (dialogContext.mounted) {
setDialogState(() => clipboardGrantInFlight = false);
}
},
child: const Text('Paste callback'),
),
ExpressiveButton(
icon: const Icon(Icons.open_in_browser),
onPressed: clipboardGrantInFlight
? null
: () {
unawaited(
_launchVerificationUrl(
authUri,
browserMode,
cancellationSignal: cancellationSignal,
),
);
},
child: Text(dialogL10n.extensionVerificationOpenBrowser),
),
],
),
);
},
);
} finally {
activeDialogContext = null;
await grantSub.cancel();
}
return true;
}
Future<void> _completeSessionGrantFromClipboard(
BuildContext context,
String extensionId,
Uri verificationUri,
) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text?.trim() ?? '';
final parsed = parseExtensionSessionGrantCallback(text);
if (parsed == null) {
messenger?.showSnackBar(
const SnackBar(content: Text('No verification callback found')),
);
return;
}
final expectedState = extensionCallbackStateFromVerificationUri(
verificationUri,
);
if (expectedState == null || parsed.state != expectedState) {
messenger?.showSnackBar(
const SnackBar(
content: Text('This callback does not match the active verification'),
),
);
return;
}
final success = await PlatformBridge.completeExtensionSessionGrant(
extensionId.trim(),
parsed.grant,
);
if (!context.mounted) return;
messenger?.showSnackBar(
SnackBar(
content: Text(
success ? 'Verification completed' : 'Verification failed',
),
),
);
} catch (e) {
if (!context.mounted) return;
messenger?.showSnackBar(
SnackBar(
content: Text(
'Verification callback failed: ${context.friendlyError(e)}',
),
),
);
}
}
/// Opens an extension auth/verification page. On iOS this prefers an
/// ASWebAuthenticationSession, which captures the spotiflac:// callback
/// in-process — required for environments where the app's URL scheme is not
/// registered with the OS (sideload containers like LiveContainer) and nicer
/// everywhere else (the sheet closes itself once the challenge completes).
Future<bool> launchExtensionAuthUrl(Uri uri, {required String browserMode}) {
return _launchVerificationUrl(uri, browserMode);
}
Future<bool> _launchVerificationUrl(
Uri uri,
String browserMode, {
Future<void>? cancellationSignal,
}) async {
var cancelled = false;
if (cancellationSignal != null) {
unawaited(cancellationSignal.then((_) => cancelled = true));
}
if (Platform.isIOS && uri.scheme == 'https') {
try {
if (await PlatformBridge.startIosWebAuthSession(uri.toString())) {
return true;
}
_log.w('Web auth session did not start, falling back to browser');
} catch (e) {
_log.w('Web auth session failed, falling back to browser: $e');
}
}
final preferInApp = browserMode.trim().toLowerCase() == 'in_app_first';
final firstMode = preferInApp
? LaunchMode.inAppBrowserView
: LaunchMode.externalApplication;
final fallbackMode = preferInApp
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView;
for (final mode in [firstMode, fallbackMode]) {
if (cancelled) return false;
try {
if (await launchUrl(uri, mode: mode)) return true;
} on PlatformException catch (error) {
// Android reports some launch failures as exceptions rather than false.
// Keep the other mode and manual verification help available in both cases.
_log.w(
'Verification browser launch failed (${mode.name}): ${error.code}',
);
}
}
return false;
}
File renamed without changes.
@@ -0,0 +1,613 @@
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/deleted_library_files.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/confirm_and_delete_tracks.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/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart';
final _reEnrichLog = AppLogger('BatchReEnrich');
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,
);
showAppSnackBar(context, 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,
sourceTrackIds: {
for (final track in downloads)
if (track.spotifyId?.trim().isNotEmpty == true)
track.id: track.spotifyId!,
},
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,
Map<String, String> sourceTrackIds = const {},
}) 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(
sourceTrackIds: sourceTrackIds,
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) {
await showReEnrichResultDialog(
context,
message: context.l10n.trackReEnrichNoChanges,
);
// Restore after the result closes so root-overlay fallback bars cannot
// cover this dialog either. Selected IDs remain intact throughout.
if (context.mounted && isActive()) onSelectionRestore();
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);
// Report file-write outcomes immediately. A scan of a large Library can
// take much longer than the batch and must not hide its completed result.
ScaffoldMessenger.of(context).clearSnackBars();
final summary = lyricsSummary.message(context.l10n, successCount, total);
final resultClosed = showReEnrichResultDialog(context, message: summary);
if (refreshLibrary != null) {
await refreshLibrary();
} else {
await _refreshReEnrichedLocalLibrary(ref);
}
if (!context.mounted || !isActive()) return;
onComplete();
await resultClosed;
}
/// 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 Map<String, String> sourceTrackIds;
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.sourceTrackIds = const {},
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,
sourceTrackId: sourceTrackIds[item.id],
),
);
final rawMetadata = result['enriched_metadata'];
if (result['method'] != 'preview' || rawMetadata is! Map) {
_reEnrichLog.w(
'Metadata lookup failed for ${item.id}: ${result['error'] ?? result['method']}',
);
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 (error, stack) {
_reEnrichLog.w('Metadata lookup failed for ${item.id}: $error');
_reEnrichLog.d('$stack');
// 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,
sourceTrackId: sourceTrackIds[preview.item.id],
),
);
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);
} else {
_reEnrichLog.w('Metadata write failed for ${preview.item.id}');
}
default:
_reEnrichLog.w(
'Re-enrich failed for ${preview.item.id}: ${result['error'] ?? result['method']}',
);
}
} catch (error, stack) {
_reEnrichLog.w('Re-enrich failed for ${preview.item.id}: $error');
_reEnrichLog.d('$stack');
// 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);
}
/// Confirms, then deletes the files behind [selected] and removes their
/// history or local-index rows. Files that cannot be deleted keep their rows.
Future<void> deleteLibraryTracks(
BuildContext context,
WidgetRef ref,
List<UnifiedLibraryItem> 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.dialogDeleteSelectedTitle),
content: Text(context.l10n.dialogDeleteSelectedMessage(selected.length)),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed != true || !isActive() || !context.mounted) return;
final container = ProviderScope.containerOf(context, listen: false);
final messenger = ScaffoldMessenger.of(context);
final l10n = context.l10n;
final deletedPaths = <String>{};
var deletedCount = 0;
for (final item in selected) {
final cleanPath = DownloadedEmbeddedCoverResolver.cleanFilePath(
item.filePath,
);
if (!await deleteFile(cleanPath)) continue;
deletedPaths.add(cleanPath);
deletedCount++;
}
await removeDeletedLibraryFileEntries(container, deletedPaths);
if (!isActive() || !context.mounted) return;
onComplete();
messenger.showSnackBar(
SnackBar(
content: Text(
trackDeletionResultMessage(
l10n,
deleted: deletedCount,
requested: selected.length,
),
),
),
);
}
+135
View File
@@ -0,0 +1,135 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/utils/logger.dart';
/// Prepares the actual backdrop pipeline, not just the fragment asset. The
/// detached scene is rasterized once and discarded without presenting a frame.
class MornyeGlassWarmup {
static Future<void>? _pending;
static bool _ready = false;
static final _log = AppLogger('MornyeGlass');
@visibleForTesting
static bool get isReady => _ready;
static Future<void> prepare(ui.FlutterView view) {
if (!ui.ImageFilter.isShaderFilterSupported) return Future<void>.value();
return _pending ??= _prepare(view).catchError((Object error) {
// Preparation is optional; keep the package's existing lazy fallback.
_log.w('Glass preparation failed: $error');
});
}
static Future<void> _prepare(ui.FlutterView view) async {
// GLES needs the view's initialized graphics context. A first-frame
// callback only means painting was submitted, not that rasterization ran.
await Future.wait<void>([
LiquidGlassShaders.ensureLoaded(),
WidgetsBinding.instance.waitUntilFirstFrameRasterized,
]);
// Do not compete with an entrance, theme switch or scrolling animation.
await SchedulerBinding.instance.scheduleTask(
() async {
final binding = WidgetsBinding.instance;
if (binding.lifecycleState != AppLifecycleState.resumed ||
!binding.platformDispatcher.views.contains(view)) {
_pending = null;
return;
}
await _rasterize(view);
_ready = true;
},
Priority.idle,
debugLabel: 'Prepare Mornye glass backdrop',
);
}
}
Future<void> _rasterize(ui.FlutterView view) async {
const size = Size(240, 180);
final boundary = RenderRepaintBoundary();
final pipeline = PipelineOwner();
final focus = FocusManager();
final owner = BuildOwner(focusManager: focus);
final renderView = RenderView(
view: view,
configuration: ViewConfiguration(
logicalConstraints: BoxConstraints.tight(size),
physicalConstraints: BoxConstraints.tight(size * view.devicePixelRatio),
devicePixelRatio: view.devicePixelRatio,
),
child: boundary,
);
pipeline.rootNode = renderView;
renderView.prepareInitialFrame();
RenderObjectToWidgetElement<RenderBox>? root;
try {
root = RenderObjectToWidgetAdapter<RenderBox>(
container: boundary,
child: ProviderScope(
overrides: [
mornyeGlassLevelProvider.overrideWithValue(MornyeGlassLevel.liquid),
],
child: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(
size: size,
devicePixelRatio: view.devicePixelRatio,
),
child: Theme(
data: MornyeTheme.build(Brightness.dark),
child: const Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xff76534b), Color(0xff385763)],
),
),
),
Padding(
padding: EdgeInsets.all(12),
child: MornyeGlassPanel.overlay(
child: Center(child: Text('Download')),
),
),
],
),
),
),
),
),
).attachToRenderTree(owner);
owner.buildScope(root);
owner.finalizeTree();
pipeline.flushLayout();
pipeline.flushCompositingBits();
pipeline.flushPaint();
final image = await boundary.toImage(pixelRatio: view.devicePixelRatio);
image.dispose();
} finally {
if (root != null) {
RenderObjectToWidgetAdapter<RenderBox>(
container: boundary,
).attachToRenderTree(owner, root);
owner.buildScope(root);
owner.finalizeTree();
}
pipeline.rootNode = null;
renderView.child = null;
renderView.dispose();
boundary.dispose();
pipeline.dispose();
focus.dispose();
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ import 'package:spotiflac_android/utils/ttl_cache.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/extension_collection_screens.dart'
show ExtensionAlbumScreen;
import 'package:spotiflac_android/utils/local_playback.dart';
import 'package:spotiflac_android/controllers/local_playback.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
@@ -16,7 +16,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/repo_provider.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/extension_action_forms.dart';
import 'package:spotiflac_android/controllers/extension_action_forms.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/utils/extension_country_name.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
+1 -675
View File
@@ -1,675 +1 @@
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/theme/app_motion.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/conversion_library_service.dart';
import 'package:spotiflac_android/services/ffmpeg_service.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/theme/mornye_theme.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/utils/logger.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/utils/saf_display_path.dart';
final _batchActionsLog = AppLogger('BatchActions');
class _BatchConversionFailure implements Exception {
final String stage;
final String message;
const _BatchConversionFailure(this.stage, this.message);
@override
String toString() => '$stage: $message';
}
/// 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;
// The queue launches this action from a temporary OverlayEntry. Its
// onSheetOpen callback removes that entry, which deactivates [context]. Keep
// the root Navigator's context before doing so; it remains mounted for the
// sheet, the confirmation dialog, progress updates, and the final snackbar.
final modalContext = Navigator.of(context, rootNavigator: true).context;
// Resolve localized strings up front; the builder must not look up
// Localizations via a possibly deactivated context.
final sheetTitle = modalContext.l10n.selectionBatchConvertConfirmTitle;
final sheetConfirmLabel = modalContext.l10n.selectionConvertCount(
selectedItems.length,
);
onSheetOpen?.call();
await showModalBottomSheet<void>(
context: modalContext,
sheetAnimationStyle: AppMotion.surfaceEntrance(modalContext),
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: modalContext.isMornye ? Colors.transparent : null,
elevation: modalContext.isMornye ? 0 : null,
builder: (sheetContext) => BatchConvertSheet(
formats: formats,
title: sheetTitle,
confirmLabel: sheetConfirmLabel,
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
onConvert:
(format, bitrate, losslessQuality, losslessProcessing, keepOriginal) {
didStartConversion = true;
Navigator.pop(sheetContext);
_performBatchConversion(
modalContext,
ref,
selectedItems: selectedItems,
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
keepOriginal: keepOriginal,
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(),
bool keepOriginal = false,
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 showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
keepOriginal
? context.l10n.selectionBatchConvertConfirmKeepOriginal(
convertibleItems.length,
targetFormat,
)
: 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: [
AppDialogAction(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDefault: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.trackConvertFormat),
),
],
),
);
if (confirmed != true || !context.mounted) return;
int successCount = 0;
final failures = <String>[];
final total = convertibleItems.length;
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);
String failureStage = 'metadata';
String? coverPath;
String? safTempPath;
String? convertedSafTempPath;
try {
final metadata = <String, String>{
'TITLE': item.trackName,
'ARTIST': item.artistName,
'ALBUM': item.albumName,
if (item.historyItem?.explicit == true ||
item.localItem?.explicit == true)
'ITUNESADVISORY': '1',
};
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,
);
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);
if (isSaf) {
failureStage = 'read SAF source';
safTempPath = await ConversionLibraryService.copySafSourceToTemp(
item.filePath,
);
if (safTempPath == null || safTempPath.isEmpty) {
throw const _BatchConversionFailure(
'read SAF source',
'temporary copy was not created',
);
}
workingPath = safTempPath;
}
failureStage = 'FFmpeg conversion';
final newPath = await FFmpegService.convertAudioFormat(
inputPath: workingPath,
targetFormat: targetFormat.toLowerCase(),
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
artistTagMode: settings.artistTagMode,
deleteOriginal: !isSaf && !keepOriginal,
sourceBitDepth: item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (newPath == null) {
throw const _BatchConversionFailure(
'FFmpeg conversion',
'converter returned no output',
);
}
if (isSaf) convertedSafTempPath = newPath;
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) {
failureStage = 'publish SAF output';
final hi = item.historyItem!;
final treeUri = hi.downloadTreeUri;
final relativeDir = hi.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
final oldFileName = hi.safFileName ?? '';
final published = await ConversionLibraryService.publishSafConversion(
treeUri: treeUri,
relativeDir: relativeDir,
originalFileName: oldFileName,
targetFormat: targetFormat,
sourcePath: newPath,
keepOriginal: keepOriginal,
);
final safUri = published?.uri;
if (safUri == null || safUri.isEmpty) {
throw const _BatchConversionFailure(
'publish SAF output',
'destination file was not created',
);
}
if (!keepOriginal && !isSameContentUri(item.filePath, safUri)) {
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
}
await ConversionLibraryService.persistHistoryConversion(
source: hi,
newFilePath: safUri,
newQuality: newQuality,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
keepOriginal: keepOriginal,
newSafFileName: published!.fileName,
);
} else {
throw const _BatchConversionFailure(
'publish SAF output',
'download folder permission is unavailable',
);
}
try {
await File(newPath).delete();
} catch (_) {}
if (safTempPath != null) {
try {
await File(safTempPath).delete();
} catch (_) {}
}
} else if (isSaf && item.localItem != null) {
failureStage = 'publish SAF output';
final location = resolveSafDocumentLocation(item.filePath);
if (location != null) {
final published = await ConversionLibraryService.publishSafConversion(
treeUri: location.treeUri,
relativeDir: location.relativeDir,
originalFileName: location.fileName,
targetFormat: targetFormat,
sourcePath: newPath,
keepOriginal: keepOriginal,
);
final safUri = published?.uri;
if (safUri == null || safUri.isEmpty) {
throw const _BatchConversionFailure(
'publish SAF output',
'destination file was not created',
);
}
if (!keepOriginal && !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,
keepOriginal: keepOriginal,
);
} else {
throw const _BatchConversionFailure(
'publish SAF output',
'source document location could not be resolved',
);
}
try {
await File(newPath).delete();
} catch (_) {}
if (safTempPath != null) {
try {
await File(safTempPath).delete();
} catch (_) {}
}
} else if (item.historyItem != null) {
await ConversionLibraryService.persistHistoryConversion(
source: item.historyItem!,
newFilePath: newPath,
newQuality: newQuality,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
keepOriginal: keepOriginal,
);
} else if (item.localItem != null) {
await LibraryDatabase.instance.replaceWithConvertedItem(
item: item.localItem!,
newFilePath: newPath,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
keepOriginal: keepOriginal,
);
}
successCount++;
} catch (error, stackTrace) {
final detail = error is _BatchConversionFailure
? error.toString()
: '$failureStage: $error';
failures.add('${item.trackName} — $detail');
_batchActionsLog.e(
'Batch conversion failed for ${item.trackName} at $failureStage',
error,
stackTrace,
);
} finally {
for (final path in <String?>[
coverPath,
safTempPath,
convertedSafTempPath,
]) {
if (path == null || path.isEmpty) continue;
try {
final file = File(path);
if (await file.exists()) await file.delete();
} catch (error) {
_batchActionsLog.w('Failed to clean temporary file $path: $error');
}
}
}
}
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
await 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)}'
'${failures.isEmpty ? '' : ' • ${context.l10n.trackConvertFailed}: ${failures.length} (${failures.first})'}',
),
),
);
}
}
/// Adds or removes ReplayGain tags for [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 sourceContext,
List<UnifiedLibraryItem> selectedItems, {
required VoidCallback onExitSelectionMode,
bool remove = false,
VoidCallback? onConfirmOpen,
Future<void> Function(bool confirmed)? onConfirmClosed,
}) async {
if (selectedItems.isEmpty) return;
final container = ProviderScope.containerOf(sourceContext, listen: false);
// The selection overlay can disappear when opening the confirmation dialog.
final context = Navigator.of(sourceContext, rootNavigator: true).context;
onConfirmOpen?.call();
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(
remove
? ctx.l10n.trackRemoveReplayGain
: ctx.l10n.replayGainBatchConfirmTitle,
),
content: Text(
remove
? ctx.l10n.replayGainRemoveConfirmMessage(selectedItems.length)
: ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length),
),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDefault: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(
remove
? ctx.l10n.trackRemoveReplayGain
: ctx.l10n.replayGainBatchConfirmTitle,
),
),
],
),
);
await onConfirmClosed?.call(confirmed == true);
if (confirmed != true || !context.mounted) return;
var cancelled = false;
int successCount = 0;
var unsupportedDecoder = false;
final total = selectedItems.length;
BatchProgressDialog.show(
context: context,
title: remove
? context.l10n.replayGainRemoving
: 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 = remove
? await ReplayGainService.removeFromFile(item.filePath)
: await ReplayGainService.applyToFile(
item.filePath,
onUnsupportedDecoder: () => unsupportedDecoder = true,
);
if (ok) {
successCount++;
try {
if (item.historyItem case final history?) {
await container
.read(downloadHistoryProvider.notifier)
.updateAudioMetadataForItem(
id: history.id,
hasReplayGain: !remove,
replayGainMetadataScanVersion: 1,
);
}
if (item.localItem case final local?) {
await LibraryDatabase.instance.updateAudioMetadata(
local.id,
hasReplayGain: !remove,
);
}
} catch (e) {
_batchActionsLog.w('Could not refresh ReplayGain availability: $e');
}
}
} catch (_) {}
}
onExitSelectionMode();
if (successCount > 0 && context.mounted) {
await container.read(localLibraryProvider.notifier).reloadFromStorage();
}
if (!context.mounted) return;
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
[
remove
? context.l10n.replayGainRemoveBatchSuccess(successCount, total)
: context.l10n.replayGainBatchSuccess(successCount, total),
if (unsupportedDecoder) context.l10n.replayGainUnsupportedDecoder,
].join('\n'),
),
),
);
}
export 'package:spotiflac_android/controllers/batch_track_actions.dart';
+1 -613
View File
@@ -1,613 +1 @@
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/deleted_library_files.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/confirm_and_delete_tracks.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/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart';
final _reEnrichLog = AppLogger('BatchReEnrich');
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,
);
showAppSnackBar(context, 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,
sourceTrackIds: {
for (final track in downloads)
if (track.spotifyId?.trim().isNotEmpty == true)
track.id: track.spotifyId!,
},
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,
Map<String, String> sourceTrackIds = const {},
}) 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(
sourceTrackIds: sourceTrackIds,
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) {
await showReEnrichResultDialog(
context,
message: context.l10n.trackReEnrichNoChanges,
);
// Restore after the result closes so root-overlay fallback bars cannot
// cover this dialog either. Selected IDs remain intact throughout.
if (context.mounted && isActive()) onSelectionRestore();
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);
// Report file-write outcomes immediately. A scan of a large Library can
// take much longer than the batch and must not hide its completed result.
ScaffoldMessenger.of(context).clearSnackBars();
final summary = lyricsSummary.message(context.l10n, successCount, total);
final resultClosed = showReEnrichResultDialog(context, message: summary);
if (refreshLibrary != null) {
await refreshLibrary();
} else {
await _refreshReEnrichedLocalLibrary(ref);
}
if (!context.mounted || !isActive()) return;
onComplete();
await resultClosed;
}
/// 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 Map<String, String> sourceTrackIds;
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.sourceTrackIds = const {},
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,
sourceTrackId: sourceTrackIds[item.id],
),
);
final rawMetadata = result['enriched_metadata'];
if (result['method'] != 'preview' || rawMetadata is! Map) {
_reEnrichLog.w(
'Metadata lookup failed for ${item.id}: ${result['error'] ?? result['method']}',
);
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 (error, stack) {
_reEnrichLog.w('Metadata lookup failed for ${item.id}: $error');
_reEnrichLog.d('$stack');
// 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,
sourceTrackId: sourceTrackIds[preview.item.id],
),
);
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);
} else {
_reEnrichLog.w('Metadata write failed for ${preview.item.id}');
}
default:
_reEnrichLog.w(
'Re-enrich failed for ${preview.item.id}: ${result['error'] ?? result['method']}',
);
}
} catch (error, stack) {
_reEnrichLog.w('Re-enrich failed for ${preview.item.id}: $error');
_reEnrichLog.d('$stack');
// 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);
}
/// Confirms, then deletes the files behind [selected] and removes their
/// history or local-index rows. Files that cannot be deleted keep their rows.
Future<void> deleteLibraryTracks(
BuildContext context,
WidgetRef ref,
List<UnifiedLibraryItem> 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.dialogDeleteSelectedTitle),
content: Text(context.l10n.dialogDeleteSelectedMessage(selected.length)),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed != true || !isActive() || !context.mounted) return;
final container = ProviderScope.containerOf(context, listen: false);
final messenger = ScaffoldMessenger.of(context);
final l10n = context.l10n;
final deletedPaths = <String>{};
var deletedCount = 0;
for (final item in selected) {
final cleanPath = DownloadedEmbeddedCoverResolver.cleanFilePath(
item.filePath,
);
if (!await deleteFile(cleanPath)) continue;
deletedPaths.add(cleanPath);
deletedCount++;
}
await removeDeletedLibraryFileEntries(container, deletedPaths);
if (!isActive() || !context.mounted) return;
onComplete();
messenger.showSnackBar(
SnackBar(
content: Text(
trackDeletionResultMessage(
l10n,
deleted: deletedCount,
requested: selected.length,
),
),
),
);
}
export 'package:spotiflac_android/controllers/local_track_batch_actions.dart';
+1 -135
View File
@@ -1,135 +1 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/utils/logger.dart';
/// Prepares the actual backdrop pipeline, not just the fragment asset. The
/// detached scene is rasterized once and discarded without presenting a frame.
class MornyeGlassWarmup {
static Future<void>? _pending;
static bool _ready = false;
static final _log = AppLogger('MornyeGlass');
@visibleForTesting
static bool get isReady => _ready;
static Future<void> prepare(ui.FlutterView view) {
if (!ui.ImageFilter.isShaderFilterSupported) return Future<void>.value();
return _pending ??= _prepare(view).catchError((Object error) {
// Preparation is optional; keep the package's existing lazy fallback.
_log.w('Glass preparation failed: $error');
});
}
static Future<void> _prepare(ui.FlutterView view) async {
// GLES needs the view's initialized graphics context. A first-frame
// callback only means painting was submitted, not that rasterization ran.
await Future.wait<void>([
LiquidGlassShaders.ensureLoaded(),
WidgetsBinding.instance.waitUntilFirstFrameRasterized,
]);
// Do not compete with an entrance, theme switch or scrolling animation.
await SchedulerBinding.instance.scheduleTask(
() async {
final binding = WidgetsBinding.instance;
if (binding.lifecycleState != AppLifecycleState.resumed ||
!binding.platformDispatcher.views.contains(view)) {
_pending = null;
return;
}
await _rasterize(view);
_ready = true;
},
Priority.idle,
debugLabel: 'Prepare Mornye glass backdrop',
);
}
}
Future<void> _rasterize(ui.FlutterView view) async {
const size = Size(240, 180);
final boundary = RenderRepaintBoundary();
final pipeline = PipelineOwner();
final focus = FocusManager();
final owner = BuildOwner(focusManager: focus);
final renderView = RenderView(
view: view,
configuration: ViewConfiguration(
logicalConstraints: BoxConstraints.tight(size),
physicalConstraints: BoxConstraints.tight(size * view.devicePixelRatio),
devicePixelRatio: view.devicePixelRatio,
),
child: boundary,
);
pipeline.rootNode = renderView;
renderView.prepareInitialFrame();
RenderObjectToWidgetElement<RenderBox>? root;
try {
root = RenderObjectToWidgetAdapter<RenderBox>(
container: boundary,
child: ProviderScope(
overrides: [
mornyeGlassLevelProvider.overrideWithValue(MornyeGlassLevel.liquid),
],
child: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(
size: size,
devicePixelRatio: view.devicePixelRatio,
),
child: Theme(
data: MornyeTheme.build(Brightness.dark),
child: const Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xff76534b), Color(0xff385763)],
),
),
),
Padding(
padding: EdgeInsets.all(12),
child: MornyeGlassPanel.overlay(
child: Center(child: Text('Download')),
),
),
],
),
),
),
),
),
).attachToRenderTree(owner);
owner.buildScope(root);
owner.finalizeTree();
pipeline.flushLayout();
pipeline.flushCompositingBits();
pipeline.flushPaint();
final image = await boundary.toImage(pixelRatio: view.devicePixelRatio);
image.dispose();
} finally {
if (root != null) {
RenderObjectToWidgetAdapter<RenderBox>(
container: boundary,
).attachToRenderTree(owner, root);
owner.buildScope(root);
owner.finalizeTree();
}
pipeline.rootNode = null;
renderView.child = null;
renderView.dispose();
boundary.dispose();
pipeline.dispose();
focus.dispose();
}
}
export 'package:spotiflac_android/controllers/mornye_glass_warmup.dart';
+1 -892
View File
@@ -1,892 +1 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.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/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/extension_collection_screens.dart'
show ExtensionArtistScreen, ExtensionAlbumScreen;
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
final _log = AppLogger('ClickableMetadata');
class _MetadataSearchResult {
final String providerId;
final List<Map<String, dynamic>> items;
const _MetadataSearchResult({required this.providerId, required this.items});
}
Future<_MetadataSearchResult?> _searchMetadataProviders(
BuildContext context,
String query, {
required String filter,
int limit = 5,
String? sourceProviderId,
}) async {
final providerIds = _metadataSearchProviderCandidates(
context,
sourceProviderId: sourceProviderId,
);
for (final providerId in providerIds) {
try {
final items = await _searchMetadataProvider(
providerId,
query,
filter: filter,
limit: limit,
);
if (items.isNotEmpty) {
return _MetadataSearchResult(providerId: providerId, items: items);
}
} catch (e) {
_log.w(
'Metadata lookup failed for provider "$providerId", filter=$filter: $e',
);
}
}
return null;
}
Future<List<Map<String, dynamic>>> _searchMetadataProvider(
String providerId,
String query, {
required String filter,
required int limit,
}) async {
return PlatformBridge.customSearchWithExtension(
providerId,
query,
options: {'filter': filter, 'limit': limit},
);
}
List<String> _metadataSearchProviderCandidates(
BuildContext context, {
String? sourceProviderId,
}) {
final container = ProviderScope.containerOf(context, listen: false);
final extensionState = container.read(extensionProvider);
final settings = container.read(settingsProvider);
final extensionNotifier = container.read(extensionProvider.notifier);
final candidates = <String>[];
void addProvider(String? providerId) {
final normalized = providerId?.trim();
if (normalized == null ||
normalized.isEmpty ||
candidates.contains(normalized) ||
!_canSearchMetadataProvider(normalized, extensionState)) {
return;
}
candidates.add(normalized);
}
addProvider(sourceProviderId);
addProvider(settings.searchProvider);
for (final providerId in extensionState.metadataProviderPriority) {
addProvider(providerId);
}
for (final providerId in extensionNotifier.getAllMetadataProviders()) {
addProvider(providerId);
}
final searchExtensions = extensionState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList(growable: false);
for (final extension in searchExtensions.where(
(ext) => ext.searchBehavior?.primary == true,
)) {
addProvider(extension.id);
}
for (final extension in searchExtensions.where(
(ext) => ext.searchBehavior?.primary != true,
)) {
addProvider(extension.id);
}
return candidates;
}
bool _canSearchMetadataProvider(
String providerId,
ExtensionState extensionState,
) {
return extensionState.extensions.any(
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == providerId,
);
}
Future<void> navigateToArtistCredits(
BuildContext context, {
required String artistNames,
String? artistIds,
String? extensionId,
}) async {
final artists = _buildArtistTapTargets(artistNames, artistIds);
if (artists.isEmpty) return;
final selected = artists.length == 1
? artists.single
: await showAppBottomSheet<_ArtistTapTarget>(
context: context,
useRootNavigator: true,
title: context.l10n.mornyeGoToArtist,
maxHeightFactor: 0.6,
builder: (context) => ListView(
shrinkWrap: true,
children: [
for (final artist in artists)
AppSheetOption(
leading: const Icon(Icons.person_outline),
title: Text(artist.name),
onTap: () => Navigator.pop(context, artist),
),
],
),
);
if (!context.mounted || selected == null) return;
await navigateToArtist(
context,
artistName: selected.name,
artistId: selected.artistId,
extensionId: extensionId,
);
}
Future<void> navigateToArtist(
BuildContext context, {
required String artistName,
String? artistId,
String? coverUrl,
String? extensionId,
}) async {
if (artistName.isEmpty) return;
final normalizedArtistId = _normalizeArtistId(artistId);
if (normalizedArtistId != null &&
_canNavigateArtistDirectly(
artistId: normalizedArtistId,
extensionId: extensionId,
)) {
_pushArtistScreen(
context,
artistId: normalizedArtistId,
artistName: artistName,
coverUrl: coverUrl,
extensionId: extensionId,
);
return;
}
_showLoadingSnackBar(context, context.l10n.clickableLookingUpArtist);
try {
final searchResult = await _searchMetadataProviders(
context,
artistName,
filter: 'artist',
limit: 20,
sourceProviderId: extensionId,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
final artistList = searchResult?.items ?? const <Map<String, dynamic>>[];
if (artistList.isEmpty) {
_showUnavailable(context, context.l10n.trackArtist);
return;
}
final bestMatch = _pickBestResultByName(artistList, artistName);
if (bestMatch == null) {
_showUnavailable(context, context.l10n.trackArtist);
return;
}
final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? artistName;
final resolvedImage = bestMatch['images'] as String?;
final resolvedProviderId = _resolveResultProviderId(
bestMatch,
searchResult?.providerId,
);
if (resolvedId.isEmpty) {
_showUnavailable(context, context.l10n.trackArtist);
return;
}
if (!context.mounted) return;
_pushArtistScreen(
context,
artistId: resolvedId,
artistName: resolvedName,
coverUrl: resolvedImage ?? coverUrl,
extensionId: resolvedProviderId,
);
} catch (e) {
_log.e('Failed to look up artist "$artistName": $e', e);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
_showUnavailable(context, context.l10n.trackArtist);
}
}
Future<void> navigateToAlbum(
BuildContext context, {
required String albumName,
String? albumId,
String? artistName,
String? coverUrl,
String? extensionId,
}) async {
if (albumName.isEmpty) return;
if (albumId != null && albumId.isNotEmpty && !_isUnknownResourceId(albumId)) {
_pushAlbumScreen(
context,
albumId: albumId,
albumName: albumName,
coverUrl: coverUrl,
extensionId: extensionId,
);
return;
}
_showLoadingSnackBar(context, 'Looking up album...');
try {
final query = artistName != null && artistName.isNotEmpty
? '$albumName $artistName'
: albumName;
final searchResult = await _searchMetadataProviders(
context,
query,
filter: 'album',
limit: 20,
sourceProviderId: extensionId,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
final albumList = searchResult?.items ?? const <Map<String, dynamic>>[];
if (albumList.isEmpty) {
_showUnavailable(context, 'Album');
return;
}
final bestMatch = _pickBestResultByName(albumList, albumName);
if (bestMatch == null) {
_showUnavailable(context, 'Album');
return;
}
final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? albumName;
final resolvedImage = bestMatch['images'] as String?;
final resolvedProviderId = _resolveResultProviderId(
bestMatch,
searchResult?.providerId,
);
if (resolvedId.isEmpty) {
_showUnavailable(context, 'Album');
return;
}
if (!context.mounted) return;
_pushAlbumScreen(
context,
albumId: resolvedId,
albumName: resolvedName,
coverUrl: resolvedImage ?? coverUrl,
extensionId: resolvedProviderId,
);
} catch (e) {
_log.e('Failed to look up album "$albumName": $e', e);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
_showUnavailable(context, 'Album');
}
}
/// Opens the album for a track, enriching sparse search results first when
/// they do not include album identity. This keeps collection actions generic:
/// any metadata extension can supply the missing track fields through search.
Future<void> navigateToTrackAlbum(BuildContext context, Track track) async {
final albumName = track.albumName.trim();
final albumId = track.albumId?.trim();
final hasAlbumId = albumId != null && !_isUnknownResourceId(albumId);
if (albumName.isNotEmpty || hasAlbumId) {
await navigateToAlbum(
context,
albumName: albumName.isNotEmpty ? albumName : context.l10n.trackAlbum,
albumId: hasAlbumId ? albumId : null,
artistName: _preferredAlbumArtist(track),
coverUrl: track.coverUrl,
extensionId: track.source,
);
return;
}
final query = [
track.name.trim(),
track.artistName.trim(),
].where((part) => part.isNotEmpty).join(' ');
if (query.isEmpty) {
_showUnavailable(context, context.l10n.trackAlbum);
return;
}
_showLoadingSnackBar(context, 'Looking up album...');
try {
final searchResult = await _searchMetadataProviders(
context,
query,
filter: 'track',
limit: 20,
sourceProviderId: track.source,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
final matches = searchResult?.items ?? const <Map<String, dynamic>>[];
final bestMatch = _pickBestResultByName(matches, track.name);
if (bestMatch == null) {
_showUnavailable(context, context.l10n.trackAlbum);
return;
}
final providerId = _resolveResultProviderId(
bestMatch,
searchResult?.providerId,
);
final resolvedTrack = Track.fromBackendMap(bestMatch, source: providerId);
final resolvedAlbumName = resolvedTrack.albumName.trim();
final resolvedAlbumId = resolvedTrack.albumId?.trim();
final hasResolvedAlbumId =
resolvedAlbumId != null && !_isUnknownResourceId(resolvedAlbumId);
if (resolvedAlbumName.isEmpty && !hasResolvedAlbumId) {
_showUnavailable(context, context.l10n.trackAlbum);
return;
}
await navigateToAlbum(
context,
albumName: resolvedAlbumName.isNotEmpty
? resolvedAlbumName
: context.l10n.trackAlbum,
albumId: hasResolvedAlbumId ? resolvedAlbumId : null,
artistName: _preferredAlbumArtist(resolvedTrack),
coverUrl: resolvedTrack.coverUrl ?? track.coverUrl,
extensionId: providerId,
);
} catch (e) {
_log.e('Failed to resolve album for track "${track.name}": $e', e);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
_showUnavailable(context, context.l10n.trackAlbum);
}
}
String _preferredAlbumArtist(Track track) {
final albumArtist = (track.albumArtist ?? '').trim();
return albumArtist.isNotEmpty ? albumArtist : track.artistName;
}
void _pushArtistScreen(
BuildContext context, {
required String artistId,
required String artistName,
String? coverUrl,
String? extensionId,
}) {
final isExtension = extensionId != null;
final resolvedProviderId = extensionId;
pushViaPreferredNavigator(
context,
(context) => isExtension && resolvedProviderId != null
? ExtensionArtistScreen(
extensionId: resolvedProviderId,
artistId: artistId,
artistName: artistName,
coverUrl: coverUrl,
)
: ArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: coverUrl,
extensionId: resolvedProviderId,
),
);
}
void _pushAlbumScreen(
BuildContext context, {
required String albumId,
required String albumName,
String? coverUrl,
String? extensionId,
}) {
final isExtension = extensionId != null;
final resolvedExtensionId = extensionId;
pushViaPreferredNavigator(
context,
(context) => isExtension && resolvedExtensionId != null
? ExtensionAlbumScreen(
extensionId: resolvedExtensionId,
albumId: albumId,
albumName: albumName,
coverUrl: coverUrl,
)
: AlbumScreen(
albumId: albumId,
albumName: albumName,
coverUrl: coverUrl,
extensionId: resolvedExtensionId,
tracks: const [],
),
);
}
/// Pushes detail pages into the active tab navigator when invoked from a root
/// modal such as Now Playing. The root modal is dismissed first so the album
/// is immediately visible instead of being hidden behind it.
void pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
final currentNavigator = Navigator.of(context);
final rootNavigator = Navigator.of(context, rootNavigator: true);
final activeTabNavigator = ShellNavigationService.activeTabNavigator();
final shouldRouteToTabNavigator =
identical(currentNavigator, rootNavigator) && activeTabNavigator != null;
if (!shouldRouteToTabNavigator) {
currentNavigator.push(MaterialPageRoute<void>(builder: builder));
return;
}
final currentRoute = ModalRoute.of(context);
final shouldPopCurrentRoute =
currentRoute != null && currentRoute.isFirst == false;
if (shouldPopCurrentRoute && currentNavigator.canPop()) {
currentNavigator.pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!activeTabNavigator.mounted) return;
activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
});
return;
}
activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
}
void _showLoadingSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const AppLoadingIndicator(size: 16, strokeWidth: 2),
const SizedBox(width: 12),
Text(message),
],
),
duration: const Duration(seconds: 10),
),
);
}
void _showUnavailable(BuildContext context, String type) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.clickableInformationUnavailable(type))),
);
}
class ClickableArtistName extends StatefulWidget {
final String artistName;
final String? artistId;
final String? coverUrl;
final String? extensionId;
final TextStyle? style;
final int? maxLines;
final TextOverflow? overflow;
final TextAlign? textAlign;
const ClickableArtistName({
super.key,
required this.artistName,
this.artistId,
this.coverUrl,
this.extensionId,
this.style,
this.maxLines,
this.overflow,
this.textAlign,
});
@override
State<ClickableArtistName> createState() => _ClickableArtistNameState();
}
class _ClickableArtistNameState extends State<ClickableArtistName> {
List<_ArtistTapTarget> _artistTargets = const [];
final List<TapGestureRecognizer> _recognizers = [];
@override
void initState() {
super.initState();
_rebuildArtistTargets();
}
@override
void didUpdateWidget(covariant ClickableArtistName oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.artistName != widget.artistName ||
oldWidget.artistId != widget.artistId ||
oldWidget.coverUrl != widget.coverUrl ||
oldWidget.extensionId != widget.extensionId) {
_rebuildArtistTargets();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
void _rebuildArtistTargets() {
_disposeRecognizers();
_artistTargets = _buildArtistTapTargets(widget.artistName, widget.artistId);
if (_artistTargets.length <= 1) return;
for (final target in _artistTargets) {
final recognizer = TapGestureRecognizer()
..onTap = () => navigateToArtist(
context,
artistName: target.name,
artistId: target.artistId,
coverUrl: widget.coverUrl,
extensionId: _extensionIdForTarget(target),
);
_recognizers.add(recognizer);
}
}
String? _extensionIdForTarget(_ArtistTapTarget target) {
if (widget.extensionId == null) return null;
if (_artistTargets.length == 1) return widget.extensionId;
return target.artistId != null ? widget.extensionId : null;
}
List<InlineSpan> _buildMultiArtistSpans() {
final spans = <InlineSpan>[];
for (var i = 0; i < _artistTargets.length; i++) {
final target = _artistTargets[i];
spans.add(
TextSpan(
text: target.name,
style: widget.style,
recognizer: _recognizers[i],
),
);
if (i < _artistTargets.length - 1) {
spans.add(TextSpan(text: ', ', style: widget.style));
}
}
return spans;
}
@override
Widget build(BuildContext context) {
if (_artistTargets.isEmpty) {
return Text(
widget.artistName,
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
textAlign: widget.textAlign,
);
}
if (_artistTargets.length == 1) {
final target = _artistTargets.first;
return GestureDetector(
onTap: () => navigateToArtist(
context,
artistName: target.name,
artistId: target.artistId,
coverUrl: widget.coverUrl,
extensionId: _extensionIdForTarget(target),
),
child: Text(
target.name,
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
textAlign: widget.textAlign,
),
);
}
return Text.rich(
TextSpan(style: widget.style, children: _buildMultiArtistSpans()),
maxLines: widget.maxLines,
overflow: widget.overflow ?? TextOverflow.clip,
textAlign: widget.textAlign ?? TextAlign.start,
);
}
}
class _ArtistTapTarget {
final String name;
final String? artistId;
const _ArtistTapTarget({required this.name, this.artistId});
}
List<_ArtistTapTarget> _buildArtistTapTargets(
String rawArtistNames,
String? rawArtistIds,
) {
final parsedIds = _parseArtistIds(rawArtistIds);
final parsedNames = splitArtistNames(
rawArtistNames,
creditedArtistCount: parsedIds.length,
);
if (parsedNames.isEmpty) return const [];
final uniqueNames = <String>[];
final seen = <String>{};
for (final parsed in parsedNames) {
final key = parsed.toLowerCase().replaceAll(RegExp(r'\s+'), ' ').trim();
if (key.isEmpty || !seen.add(key)) continue;
uniqueNames.add(parsed);
}
if (uniqueNames.isEmpty) return const [];
if (uniqueNames.length == 1) {
return [
_ArtistTapTarget(
name: uniqueNames.first,
artistId: _normalizeArtistId(rawArtistIds),
),
];
}
if (parsedIds.isEmpty || !parsedIds.any((id) => id != null)) {
return uniqueNames
.map((name) => _ArtistTapTarget(name: name))
.toList(growable: false);
}
// Providers may return one id per artist (aligned with the names) or only
// the primary artist's id. Map ids to names positionally, preserving empty
// slots as null, so each tapped artist navigates by its own id when known.
return List<_ArtistTapTarget>.generate(
uniqueNames.length,
(index) => _ArtistTapTarget(
name: uniqueNames[index],
artistId: index < parsedIds.length ? parsedIds[index] : null,
),
growable: false,
);
}
List<String?> _parseArtistIds(String? rawArtistIds) {
final raw = rawArtistIds?.trim();
if (raw == null || raw.isEmpty) return const [];
return raw
.split(RegExp(r'\s*,\s*'))
.map(_normalizeArtistId)
.toList(growable: false);
}
String? _normalizeArtistId(String? artistId) {
final id = artistId?.trim();
if (id == null || _isUnknownResourceId(id)) {
return null;
}
return id;
}
bool _isUnknownResourceId(String id) {
final normalized = id.trim().toLowerCase();
return normalized.isEmpty ||
normalized == 'unknown' ||
normalized.endsWith(':unknown');
}
String? _resolveResultProviderId(
Map<String, dynamic> result,
String? fallbackProviderId,
) {
final providerId = result['provider_id']?.toString().trim();
if (providerId != null && providerId.isNotEmpty) return providerId;
final source = result['source']?.toString().trim();
if (source != null && source.isNotEmpty) return source;
final fallback = fallbackProviderId?.trim();
return fallback != null && fallback.isNotEmpty ? fallback : null;
}
bool _canNavigateArtistDirectly({
required String artistId,
required String? extensionId,
}) {
if (extensionId != null) return true;
return _spotifyArtistIdPattern.hasMatch(artistId);
}
/// Selects the result whose name best matches [query] instead of blindly
/// trusting the provider's first result. This prevents tapping an artist like
/// "creo" from opening a completely unrelated artist (e.g. "Tyler, the
/// Creator") just because the provider ranked it first.
Map<String, dynamic>? _pickBestResultByName(
List<Map<String, dynamic>> results,
String query,
) {
if (results.isEmpty) return null;
final normalizedQuery = _normalizeForMatch(query);
if (normalizedQuery.isEmpty) return results.first;
Map<String, dynamic>? best;
double bestScore = -1;
for (final result in results) {
final name = result['name'] as String? ?? '';
final normalizedName = _normalizeForMatch(name);
if (normalizedName.isEmpty) continue;
if (normalizedName == normalizedQuery) {
return result;
}
final score = _nameMatchScore(normalizedQuery, normalizedName);
if (score > bestScore) {
bestScore = score;
best = result;
}
}
// Accept a fuzzy match only when it is reasonably close. Otherwise fall back
// to the provider's own top result so we never crash on an empty match.
if (best != null && bestScore >= 0.5) {
if (bestScore < 0.85) {
_log.w(
'No exact match for "$query"; using closest result '
'"${best['name']}" (score ${bestScore.toStringAsFixed(2)})',
);
}
return best;
}
_log.w(
'No close match for "$query" among ${results.length} results; '
'falling back to first result "${results.first['name']}"',
);
return results.first;
}
String _normalizeForMatch(String value) {
return value
.toLowerCase()
.replaceAll(RegExp(r'[^\p{L}\p{N}\s]', unicode: true), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
/// Returns a similarity score in [0, 1] between two normalized names.
double _nameMatchScore(String query, String candidate) {
if (query == candidate) return 1.0;
final queryTokens = query.split(' ').where((t) => t.isNotEmpty).toSet();
final candidateTokens = candidate
.split(' ')
.where((t) => t.isNotEmpty)
.toSet();
if (queryTokens.isEmpty || candidateTokens.isEmpty) return 0;
final intersection = queryTokens.intersection(candidateTokens).length;
final union = queryTokens.union(candidateTokens).length;
final jaccard = union == 0 ? 0.0 : intersection / union;
// Reward full substring containment of the (shorter) query in the candidate.
double containment = 0;
if (candidate.contains(query) || query.contains(candidate)) {
final shorter = query.length < candidate.length ? query : candidate;
final longer = query.length < candidate.length ? candidate : query;
containment = longer.isEmpty ? 0 : shorter.length / longer.length;
}
return jaccard > containment ? jaccard : containment;
}
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
class ClickableAlbumName extends StatelessWidget {
final String albumName;
final String? albumId;
final String? artistName;
final String? coverUrl;
final String? extensionId;
final TextStyle? style;
final int? maxLines;
final TextOverflow? overflow;
final TextAlign? textAlign;
const ClickableAlbumName({
super.key,
required this.albumName,
this.albumId,
this.artistName,
this.coverUrl,
this.extensionId,
this.style,
this.maxLines,
this.overflow,
this.textAlign,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => navigateToAlbum(
context,
albumName: albumName,
albumId: albumId,
artistName: artistName,
coverUrl: coverUrl,
extensionId: extensionId,
),
child: Text(
albumName,
style: style,
maxLines: maxLines,
overflow: overflow,
textAlign: textAlign,
),
);
}
}
export 'package:spotiflac_android/controllers/clickable_metadata.dart';
+1 -83
View File
@@ -1,83 +1 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
String trackDeletionResultMessage(
AppLocalizations l10n, {
required int deleted,
required int requested,
}) => [
if (deleted > 0 || requested == 0) l10n.snackbarDeletedTracks(deleted),
if (deleted < requested) l10n.snackbarDeleteTracksFailed(requested - deleted),
].join('\n');
/// Shows a delete-confirmation dialog, deletes the given [ids] one by one via
/// [deleteItem], then shows a "deleted N tracks" snackbar.
/// [onExitSelectionMode] runs after persistence if the screen is still open.
///
/// Returns the number of items [deleteItem] reported deleted, or null if the
/// user cancelled the dialog.
/// [persistDeletedItems] commits successfully deleted IDs once before the UI
/// reports completion, including partial success if a later deletion throws.
Future<int?> confirmAndDeleteTracks({
required BuildContext context,
required List<String> ids,
required Future<bool> Function(String id) deleteItem,
Future<void> Function(List<String> ids)? persistDeletedItems,
required VoidCallback onExitSelectionMode,
}) async {
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(ids.length)),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed != true || !context.mounted) return null;
final deletedIds = <String>[];
try {
for (final id in ids) {
if (await deleteItem(id)) deletedIds.add(id);
}
} finally {
if (deletedIds.isNotEmpty && persistDeletedItems != null) {
await persistDeletedItems(deletedIds);
}
}
final deletedCount = deletedIds.length;
if (context.mounted) {
onExitSelectionMode();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
trackDeletionResultMessage(
context.l10n,
deleted: deletedCount,
requested: ids.length,
),
),
),
);
}
return deletedCount;
}
export 'package:spotiflac_android/controllers/confirm_and_delete_tracks.dart';
+1 -558
View File
@@ -1,558 +1 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/extension_session_callback.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:url_launcher/url_launcher.dart';
final _log = AppLogger('ExtensionAuthLauncher');
const extensionVerificationGrantTimeout = Duration(minutes: 3);
bool isExtensionVerificationRequired(Object error) {
final message = error.toString().toLowerCase();
return message.contains('verify_required') ||
message.contains('verification_required') ||
message.contains('verification required') ||
message.contains('needsverification') ||
message.contains('needs verification') ||
message.contains('session is not authenticated') ||
message.contains('signed session expired');
}
Future<T> runExtensionOperationWithVerificationRetry<T>({
required String extensionId,
required String browserMode,
required Future<T> Function() operation,
Future<bool> Function()? verify,
}) async {
try {
return await operation();
} catch (error) {
if (!isExtensionVerificationRequired(error)) rethrow;
final verified =
await (verify ??
() => openVerificationAndAwaitGrant(
extensionId,
browserMode: browserMode,
))();
if (!verified) rethrow;
return operation();
}
}
String? extensionIdFromVerificationError(
Object error,
Iterable<String> knownExtensionIds,
) {
final idsByNormalized = <String, String>{
for (final id in knownExtensionIds)
if (id.trim().isNotEmpty) id.trim().toLowerCase(): id.trim(),
};
if (idsByNormalized.isEmpty) return null;
final message = error.toString();
final canonicalMatch = RegExp(
r'''extension\s+['"]([^'"]+)['"]''',
caseSensitive: false,
).firstMatch(message);
final canonicalId = canonicalMatch?.group(1)?.trim().toLowerCase();
if (canonicalId != null && idsByNormalized.containsKey(canonicalId)) {
return idsByNormalized[canonicalId];
}
final lowerMessage = message.toLowerCase();
final orderedIds = idsByNormalized.keys.toList()
..sort((a, b) => b.length.compareTo(a.length));
for (final normalizedId in orderedIds) {
if (lowerMessage.contains(normalizedId)) {
return idsByNormalized[normalizedId];
}
}
return null;
}
Future<bool> openPendingExtensionVerification(
String extensionId, {
String browserMode = 'in_app_first',
void Function(Uri authUri)? onAuthUri,
Future<void>? cancellationSignal,
}) async {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return false;
var cancelled = false;
if (cancellationSignal != null) {
unawaited(cancellationSignal.then((_) => cancelled = true));
}
try {
final pending = await _awaitVerificationStepOrCancellation(
PlatformBridge.getExtensionPendingAuth(normalizedExtensionId),
cancellationSignal,
);
if (cancelled) return false;
final authUrl = pending?['auth_url']?.toString().trim() ?? '';
if (authUrl.isEmpty) {
_log.w('No pending verification challenge for $normalizedExtensionId');
showExtensionVerificationUnavailable(normalizedExtensionId);
return false;
}
final uri = Uri.tryParse(authUrl);
if (uri == null) return false;
onAuthUri?.call(uri);
final launched = await _awaitVerificationStepOrCancellation(
_launchVerificationUrl(
uri,
browserMode,
cancellationSignal: cancellationSignal,
),
cancellationSignal,
);
if (launched == null) return false;
if (launched) {
_log.i('Opened verification challenge for $normalizedExtensionId');
} else {
_log.w(
'Could not open verification challenge for $normalizedExtensionId',
);
return await showExtensionVerificationHelpDialog(
normalizedExtensionId,
uri,
browserMode: browserMode,
immediateFailure: true,
cancellationSignal: cancellationSignal,
);
}
return launched;
} catch (e) {
_log.w(
'Failed to open verification challenge for $normalizedExtensionId: $e',
);
if (!cancelled) showExtensionVerificationUnavailable(normalizedExtensionId);
return false;
}
}
void showExtensionVerificationUnavailable(String extensionId) {
final context = AppNavigationService.rootNavigatorKey.currentContext;
if (context == null || !context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(context.l10n.extensionVerificationUnavailable(extensionId)),
duration: const Duration(seconds: 8),
),
);
}
Timer? scheduleExtensionVerificationHelpDialog(
String extensionId,
Uri? authUri, {
String browserMode = 'in_app_first',
Duration delay = const Duration(seconds: 20),
Future<void>? cancellationSignal,
}) {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty || authUri == null) return null;
return Timer(delay, () {
unawaited(
showExtensionVerificationHelpDialog(
normalizedExtensionId,
authUri,
browserMode: browserMode,
cancellationSignal: cancellationSignal,
),
);
});
}
/// Opens a pending extension verification challenge and waits (up to 3
/// minutes, including pending lookup and browser launch) for its grant result,
/// returning whether it succeeded. When
/// [cancellationSignal] completes, all local waiting resources are released
/// and the method returns false without waiting for the timeout.
///
/// [awaitForeground], if given, is awaited first — passed the trimmed
/// [extensionId] — before opening the challenge; used by callers that must
/// defer verification until the app is foregrounded (launching a browser
/// from the background is blocked by the OS).
Future<bool> openVerificationAndAwaitGrant(
String extensionId, {
required String browserMode,
Future<void> Function(String extensionId)? awaitForeground,
Future<void>? cancellationSignal,
}) async {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return false;
if (awaitForeground != null) {
final reachedForeground = await _awaitVerificationStepOrCancellation(
awaitForeground(normalizedExtensionId).then((_) => true),
cancellationSignal,
);
if (reachedForeground != true) return false;
}
final grantCompleter = Completer<ExtensionSessionGrantEvent>();
late final StreamSubscription<ExtensionSessionGrantEvent> grantSub;
grantSub = PlatformBridge.extensionSessionGrantEvents()
.where((event) => event.extensionId.trim() == normalizedExtensionId)
.listen((event) {
if (!grantCompleter.isCompleted) {
grantCompleter.complete(event);
}
});
Uri? authUri;
Timer? helpDialogTimer;
final stopped = Completer<void>();
void stop() {
if (!stopped.isCompleted) stopped.complete();
}
if (cancellationSignal != null) {
unawaited(cancellationSignal.then((_) => stop()));
}
// The browser or manual-help dialog may never return. Bound the complete
// foreground flow, not only the grant wait reached after opening the page.
final deadline = Timer(extensionVerificationGrantTimeout, () {
_log.w('Timed out waiting for verification grant: $normalizedExtensionId');
stop();
});
try {
final opened = await _awaitVerificationStepOrCancellation(
openPendingExtensionVerification(
normalizedExtensionId,
browserMode: browserMode,
onAuthUri: (uri) => authUri = uri,
cancellationSignal: stopped.future,
),
stopped.future,
);
if (opened != true) return false;
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
normalizedExtensionId,
authUri,
browserMode: browserMode,
cancellationSignal: stopped.future,
);
final event = await _awaitVerificationStepOrCancellation(
grantCompleter.future,
stopped.future,
);
if (event == null) {
_log.i('Stopped waiting for verification grant: $normalizedExtensionId');
return false;
}
return event.success;
} on TimeoutException {
_log.w('Timed out waiting for verification grant: $normalizedExtensionId');
return false;
} finally {
deadline.cancel();
helpDialogTimer?.cancel();
stop();
await grantSub.cancel();
}
}
Future<T?> _awaitVerificationStepOrCancellation<T>(
Future<T> operation,
Future<void>? cancellationSignal,
) {
if (cancellationSignal == null) {
return operation.then<T?>((value) => value);
}
return Future.any<T?>([
operation.then<T?>((value) => value),
cancellationSignal.then<T?>((_) => null),
]);
}
Future<bool> showExtensionVerificationHelpDialog(
String extensionId,
Uri authUri, {
String browserMode = 'in_app_first',
bool immediateFailure = false,
Future<void>? cancellationSignal,
}) async {
final context = AppNavigationService.rootNavigatorKey.currentContext;
if (context == null) {
_log.w('Cannot show verification help dialog without root context');
return false;
}
final l10n = context.l10n;
final title = immediateFailure
? l10n.extensionVerificationHelpTitleManual
: l10n.extensionVerificationHelpTitleWaiting;
final message = immediateFailure
? l10n.extensionVerificationHelpMessageManual
: l10n.extensionVerificationHelpMessageWaiting;
final normalizedExtensionId = extensionId.trim();
BuildContext? activeDialogContext;
var closeRequested = false;
void closeDialog() {
closeRequested = true;
final dialogContext = activeDialogContext;
if (dialogContext == null || !dialogContext.mounted) return;
final route = ModalRoute.of(dialogContext);
if (route == null || !route.isActive) return;
final navigator = Navigator.of(dialogContext, rootNavigator: true);
if (route.isCurrent) {
navigator.pop();
} else {
// A newer dialog may be above this one; dismiss only our own route.
navigator.removeRoute(route);
}
}
if (cancellationSignal != null) {
unawaited(cancellationSignal.then((_) => closeDialog()));
}
var clipboardGrantInFlight = false;
late final StreamSubscription<ExtensionSessionGrantEvent> grantSub;
grantSub = PlatformBridge.extensionSessionGrantEvents()
.where(
(event) =>
event.success && event.extensionId.trim() == normalizedExtensionId,
)
.listen((_) {
_log.i(
'Closing verification help dialog after $normalizedExtensionId grant',
);
closeDialog();
});
try {
await showAppDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
activeDialogContext = dialogContext;
if (closeRequested) {
WidgetsBinding.instance.addPostFrameCallback((_) => closeDialog());
}
final dialogL10n = dialogContext.l10n;
return StatefulBuilder(
builder: (dialogContext, setDialogState) => AppAlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(message),
const SizedBox(height: 16),
DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(
dialogContext,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: SelectableText(
authUri.toString(),
maxLines: 4,
minLines: 1,
),
),
),
],
),
actions: [
ExpressiveButton(
text: true,
onPressed: clipboardGrantInFlight
? null
: () => Navigator.of(dialogContext).pop(),
child: Text(dialogL10n.extensionVerificationClose),
),
ExpressiveButton(
text: true,
icon: const Icon(Icons.copy),
onPressed: clipboardGrantInFlight
? null
: () {
Clipboard.setData(
ClipboardData(text: authUri.toString()),
);
ScaffoldMessenger.maybeOf(dialogContext)?.showSnackBar(
SnackBar(
content: Text(
dialogL10n.extensionVerificationLinkCopied,
),
),
);
},
child: Text(dialogL10n.extensionVerificationCopyLink),
),
ExpressiveButton(
text: true,
icon: clipboardGrantInFlight
? const AppLoadingIndicator(size: 18, strokeWidth: 2)
: const Icon(Icons.content_paste),
onPressed: clipboardGrantInFlight
? null
: () async {
setDialogState(() => clipboardGrantInFlight = true);
await _completeSessionGrantFromClipboard(
dialogContext,
extensionId,
authUri,
);
if (dialogContext.mounted) {
setDialogState(() => clipboardGrantInFlight = false);
}
},
child: const Text('Paste callback'),
),
ExpressiveButton(
icon: const Icon(Icons.open_in_browser),
onPressed: clipboardGrantInFlight
? null
: () {
unawaited(
_launchVerificationUrl(
authUri,
browserMode,
cancellationSignal: cancellationSignal,
),
);
},
child: Text(dialogL10n.extensionVerificationOpenBrowser),
),
],
),
);
},
);
} finally {
activeDialogContext = null;
await grantSub.cancel();
}
return true;
}
Future<void> _completeSessionGrantFromClipboard(
BuildContext context,
String extensionId,
Uri verificationUri,
) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text?.trim() ?? '';
final parsed = parseExtensionSessionGrantCallback(text);
if (parsed == null) {
messenger?.showSnackBar(
const SnackBar(content: Text('No verification callback found')),
);
return;
}
final expectedState = extensionCallbackStateFromVerificationUri(
verificationUri,
);
if (expectedState == null || parsed.state != expectedState) {
messenger?.showSnackBar(
const SnackBar(
content: Text('This callback does not match the active verification'),
),
);
return;
}
final success = await PlatformBridge.completeExtensionSessionGrant(
extensionId.trim(),
parsed.grant,
);
if (!context.mounted) return;
messenger?.showSnackBar(
SnackBar(
content: Text(
success ? 'Verification completed' : 'Verification failed',
),
),
);
} catch (e) {
if (!context.mounted) return;
messenger?.showSnackBar(
SnackBar(
content: Text(
'Verification callback failed: ${context.friendlyError(e)}',
),
),
);
}
}
/// Opens an extension auth/verification page. On iOS this prefers an
/// ASWebAuthenticationSession, which captures the spotiflac:// callback
/// in-process — required for environments where the app's URL scheme is not
/// registered with the OS (sideload containers like LiveContainer) and nicer
/// everywhere else (the sheet closes itself once the challenge completes).
Future<bool> launchExtensionAuthUrl(Uri uri, {required String browserMode}) {
return _launchVerificationUrl(uri, browserMode);
}
Future<bool> _launchVerificationUrl(
Uri uri,
String browserMode, {
Future<void>? cancellationSignal,
}) async {
var cancelled = false;
if (cancellationSignal != null) {
unawaited(cancellationSignal.then((_) => cancelled = true));
}
if (Platform.isIOS && uri.scheme == 'https') {
try {
if (await PlatformBridge.startIosWebAuthSession(uri.toString())) {
return true;
}
_log.w('Web auth session did not start, falling back to browser');
} catch (e) {
_log.w('Web auth session failed, falling back to browser: $e');
}
}
final preferInApp = browserMode.trim().toLowerCase() == 'in_app_first';
final firstMode = preferInApp
? LaunchMode.inAppBrowserView
: LaunchMode.externalApplication;
final fallbackMode = preferInApp
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView;
for (final mode in [firstMode, fallbackMode]) {
if (cancelled) return false;
try {
if (await launchUrl(uri, mode: mode)) return true;
} on PlatformException catch (error) {
// Android reports some launch failures as exceptions rather than false.
// Keep the other mode and manual verification help available in both cases.
_log.w(
'Verification browser launch failed (${mode.name}): ${error.code}',
);
}
}
return false;
}
export 'package:spotiflac_android/controllers/extension_auth_launcher.dart';
@@ -11,7 +11,7 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/local_playback.dart';
import 'package:spotiflac_android/controllers/local_playback.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
import 'package:share_plus/share_plus.dart';
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/local_playback.dart';
import 'package:spotiflac_android/controllers/local_playback.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/track_card.dart';
import 'package:spotiflac_android/widgets/in_library_badge.dart';
+1 -1
View File
@@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/extension_action_forms.dart';
import 'package:spotiflac_android/controllers/extension_action_forms.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'support/fixed_settings.dart';