diff --git a/lib/controllers/batch_track_actions.dart b/lib/controllers/batch_track_actions.dart new file mode 100644 index 00000000..ee47b346 --- /dev/null +++ b/lib/controllers/batch_track_actions.dart @@ -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 showBatchConvertSheet( + BuildContext context, + WidgetRef ref, + List selectedItems, { + required VoidCallback onExitSelectionMode, + VoidCallback? onSheetOpen, + Future Function(bool didStartConversion)? onSheetClosed, +}) async { + final sourceFormats = {}; + final sourceBitDepths = []; + final sourceSampleRates = []; + 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( + 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 _performBatchConversion( + BuildContext context, + WidgetRef ref, { + required List selectedItems, + required String targetFormat, + required String bitrate, + LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), + bool keepOriginal = false, + required VoidCallback onExitSelectionMode, +}) async { + final convertibleItems = []; + 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( + 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 = []; + 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 = { + '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 [ + 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 runBatchReplayGain( + BuildContext sourceContext, + List selectedItems, { + required VoidCallback onExitSelectionMode, + bool remove = false, + VoidCallback? onConfirmOpen, + Future 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( + 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'), + ), + ), + ); +} diff --git a/lib/controllers/clickable_metadata.dart b/lib/controllers/clickable_metadata.dart new file mode 100644 index 00000000..1cf669ed --- /dev/null +++ b/lib/controllers/clickable_metadata.dart @@ -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> 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>> _searchMetadataProvider( + String providerId, + String query, { + required String filter, + required int limit, +}) async { + return PlatformBridge.customSearchWithExtension( + providerId, + query, + options: {'filter': filter, 'limit': limit}, + ); +} + +List _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 = []; + + 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 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 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 >[]; + 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 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 >[]; + 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 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 >[]; + 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(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(builder: builder)); + }); + return; + } + + activeTabNavigator.push(MaterialPageRoute(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 createState() => _ClickableArtistNameState(); +} + +class _ClickableArtistNameState extends State { + List<_ArtistTapTarget> _artistTargets = const []; + final List _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 _buildMultiArtistSpans() { + final spans = []; + 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 = []; + final seen = {}; + 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 _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 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? _pickBestResultByName( + List> results, + String query, +) { + if (results.isEmpty) return null; + + final normalizedQuery = _normalizeForMatch(query); + if (normalizedQuery.isEmpty) return results.first; + + Map? 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, + ), + ); + } +} diff --git a/lib/controllers/confirm_and_delete_tracks.dart b/lib/controllers/confirm_and_delete_tracks.dart new file mode 100644 index 00000000..78227094 --- /dev/null +++ b/lib/controllers/confirm_and_delete_tracks.dart @@ -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 confirmAndDeleteTracks({ + required BuildContext context, + required List ids, + required Future Function(String id) deleteItem, + Future Function(List ids)? persistDeletedItems, + required VoidCallback onExitSelectionMode, +}) async { + final confirmed = await showAppDialog( + 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 = []; + 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; +} diff --git a/lib/utils/extension_action_forms.dart b/lib/controllers/extension_action_forms.dart similarity index 100% rename from lib/utils/extension_action_forms.dart rename to lib/controllers/extension_action_forms.dart diff --git a/lib/controllers/extension_auth_launcher.dart b/lib/controllers/extension_auth_launcher.dart new file mode 100644 index 00000000..d703a292 --- /dev/null +++ b/lib/controllers/extension_auth_launcher.dart @@ -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 runExtensionOperationWithVerificationRetry({ + required String extensionId, + required String browserMode, + required Future Function() operation, + Future 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 knownExtensionIds, +) { + final idsByNormalized = { + 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 openPendingExtensionVerification( + String extensionId, { + String browserMode = 'in_app_first', + void Function(Uri authUri)? onAuthUri, + Future? 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? 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 openVerificationAndAwaitGrant( + String extensionId, { + required String browserMode, + Future Function(String extensionId)? awaitForeground, + Future? 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(); + late final StreamSubscription 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 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 _awaitVerificationStepOrCancellation( + Future operation, + Future? cancellationSignal, +) { + if (cancellationSignal == null) { + return operation.then((value) => value); + } + return Future.any([ + operation.then((value) => value), + cancellationSignal.then((_) => null), + ]); +} + +Future showExtensionVerificationHelpDialog( + String extensionId, + Uri authUri, { + String browserMode = 'in_app_first', + bool immediateFailure = false, + Future? 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 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( + 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 _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 launchExtensionAuthUrl(Uri uri, {required String browserMode}) { + return _launchVerificationUrl(uri, browserMode); +} + +Future _launchVerificationUrl( + Uri uri, + String browserMode, { + Future? 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; +} diff --git a/lib/utils/local_playback.dart b/lib/controllers/local_playback.dart similarity index 100% rename from lib/utils/local_playback.dart rename to lib/controllers/local_playback.dart diff --git a/lib/controllers/local_track_batch_actions.dart b/lib/controllers/local_track_batch_actions.dart new file mode 100644 index 00000000..5af1966b --- /dev/null +++ b/lib/controllers/local_track_batch_actions.dart @@ -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 queueLocalTracksAsFlac( + BuildContext context, + WidgetRef ref, + List selected, { + required bool Function() isActive, + required VoidCallback onComplete, +}) async { + if (selected.isEmpty) { + return; + } + + final confirmed = await showAppDialog( + 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 reEnrichLibraryTracks( + BuildContext context, + WidgetRef ref, + List selected, { + required bool Function() isActive, + required Future Function() onSelectionHide, + required VoidCallback onSelectionRestore, + required VoidCallback onComplete, +}) async { + if (selected.isEmpty) return; + final downloads = selected + .map((item) => item.historyItem) + .whereType() + .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 _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 reEnrichLocalTracks( + BuildContext context, + WidgetRef ref, + List selected, { + required bool Function() isActive, + required Future Function() onSelectionHide, + required VoidCallback onSelectionRestore, + required VoidCallback onComplete, + Future Function()? refreshLibrary, + Map 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 sourceTrackIds; + final Future Function() beginPhase; + final Future> Function(Map) reEnrich; + final Future Function({ + required String audioFilePath, + required Map reEnrichResult, + }) + writeSidecar; + final Future Function({ + required LocalLibraryItem item, + required Map result, + required String artistTagMode, + }) + applyFfmpeg; + + BatchReEnrichRunner({ + required this.beginPhase, + this.sourceTrackIds = const {}, + this.reEnrich = PlatformBridge.reEnrichFile, + this.writeSidecar = writeReEnrichSidecarLrc, + this.applyFfmpeg = applyFfmpegReEnrichResult, + }); + + Future> preview( + List items, + ReEnrichFieldSelection selection, { + required bool Function() shouldStop, + required void Function(int, LocalLibraryItem) onProgress, + }) async { + if (shouldStop()) return []; + final settings = await beginPhase(); + final previews = []; + 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 apply( + List previews, { + required bool Function() shouldStop, + required void Function(int, LocalLibraryItem) onProgress, + void Function(Map)? 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 tracks, int skipped})> matchLocalTracksForFlac( + List items, { + required Future Function(LocalLibraryItem) + resolve, + required bool Function() shouldStop, + required void Function(int, LocalLibraryItem) onProgress, +}) async { + final tracks = []; + 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 deleteLibraryTracks( + BuildContext context, + WidgetRef ref, + List selected, { + required bool Function() isActive, + required VoidCallback onComplete, +}) async { + if (selected.isEmpty) return; + final confirmed = await showAppDialog( + 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 = {}; + 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, + ), + ), + ), + ); +} diff --git a/lib/controllers/mornye_glass_warmup.dart b/lib/controllers/mornye_glass_warmup.dart new file mode 100644 index 00000000..478439c1 --- /dev/null +++ b/lib/controllers/mornye_glass_warmup.dart @@ -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? _pending; + static bool _ready = false; + static final _log = AppLogger('MornyeGlass'); + + @visibleForTesting + static bool get isReady => _ready; + + static Future prepare(ui.FlutterView view) { + if (!ui.ImageFilter.isShaderFilterSupported) return Future.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 _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([ + 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 _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? root; + try { + root = RenderObjectToWidgetAdapter( + 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( + container: boundary, + ).attachToRenderTree(owner, root); + owner.buildScope(root); + owner.finalizeTree(); + } + pipeline.rootNode = null; + renderView.child = null; + renderView.dispose(); + boundary.dispose(); + pipeline.dispose(); + focus.dispose(); + } +} diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 74ddc01f..cdd95e12 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -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'; diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 7826f208..f1953529 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.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'; diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart index ee47b346..30148554 100644 --- a/lib/services/batch_track_actions.dart +++ b/lib/services/batch_track_actions.dart @@ -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 showBatchConvertSheet( - BuildContext context, - WidgetRef ref, - List selectedItems, { - required VoidCallback onExitSelectionMode, - VoidCallback? onSheetOpen, - Future Function(bool didStartConversion)? onSheetClosed, -}) async { - final sourceFormats = {}; - final sourceBitDepths = []; - final sourceSampleRates = []; - 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( - 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 _performBatchConversion( - BuildContext context, - WidgetRef ref, { - required List selectedItems, - required String targetFormat, - required String bitrate, - LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), - LosslessConversionProcessing losslessProcessing = - const LosslessConversionProcessing(), - bool keepOriginal = false, - required VoidCallback onExitSelectionMode, -}) async { - final convertibleItems = []; - 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( - 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 = []; - 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 = { - '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 [ - 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 runBatchReplayGain( - BuildContext sourceContext, - List selectedItems, { - required VoidCallback onExitSelectionMode, - bool remove = false, - VoidCallback? onConfirmOpen, - Future 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( - 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'; diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart index 5af1966b..3982d6f6 100644 --- a/lib/services/local_track_batch_actions.dart +++ b/lib/services/local_track_batch_actions.dart @@ -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 queueLocalTracksAsFlac( - BuildContext context, - WidgetRef ref, - List selected, { - required bool Function() isActive, - required VoidCallback onComplete, -}) async { - if (selected.isEmpty) { - return; - } - - final confirmed = await showAppDialog( - 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 reEnrichLibraryTracks( - BuildContext context, - WidgetRef ref, - List selected, { - required bool Function() isActive, - required Future Function() onSelectionHide, - required VoidCallback onSelectionRestore, - required VoidCallback onComplete, -}) async { - if (selected.isEmpty) return; - final downloads = selected - .map((item) => item.historyItem) - .whereType() - .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 _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 reEnrichLocalTracks( - BuildContext context, - WidgetRef ref, - List selected, { - required bool Function() isActive, - required Future Function() onSelectionHide, - required VoidCallback onSelectionRestore, - required VoidCallback onComplete, - Future Function()? refreshLibrary, - Map 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 sourceTrackIds; - final Future Function() beginPhase; - final Future> Function(Map) reEnrich; - final Future Function({ - required String audioFilePath, - required Map reEnrichResult, - }) - writeSidecar; - final Future Function({ - required LocalLibraryItem item, - required Map result, - required String artistTagMode, - }) - applyFfmpeg; - - BatchReEnrichRunner({ - required this.beginPhase, - this.sourceTrackIds = const {}, - this.reEnrich = PlatformBridge.reEnrichFile, - this.writeSidecar = writeReEnrichSidecarLrc, - this.applyFfmpeg = applyFfmpegReEnrichResult, - }); - - Future> preview( - List items, - ReEnrichFieldSelection selection, { - required bool Function() shouldStop, - required void Function(int, LocalLibraryItem) onProgress, - }) async { - if (shouldStop()) return []; - final settings = await beginPhase(); - final previews = []; - 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 apply( - List previews, { - required bool Function() shouldStop, - required void Function(int, LocalLibraryItem) onProgress, - void Function(Map)? 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 tracks, int skipped})> matchLocalTracksForFlac( - List items, { - required Future Function(LocalLibraryItem) - resolve, - required bool Function() shouldStop, - required void Function(int, LocalLibraryItem) onProgress, -}) async { - final tracks = []; - 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 deleteLibraryTracks( - BuildContext context, - WidgetRef ref, - List selected, { - required bool Function() isActive, - required VoidCallback onComplete, -}) async { - if (selected.isEmpty) return; - final confirmed = await showAppDialog( - 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 = {}; - 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'; diff --git a/lib/services/mornye_glass_warmup.dart b/lib/services/mornye_glass_warmup.dart index 478439c1..a429fd15 100644 --- a/lib/services/mornye_glass_warmup.dart +++ b/lib/services/mornye_glass_warmup.dart @@ -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? _pending; - static bool _ready = false; - static final _log = AppLogger('MornyeGlass'); - - @visibleForTesting - static bool get isReady => _ready; - - static Future prepare(ui.FlutterView view) { - if (!ui.ImageFilter.isShaderFilterSupported) return Future.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 _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([ - 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 _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? root; - try { - root = RenderObjectToWidgetAdapter( - 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( - 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'; diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index 1cf669ed..7419ad1a 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -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> 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>> _searchMetadataProvider( - String providerId, - String query, { - required String filter, - required int limit, -}) async { - return PlatformBridge.customSearchWithExtension( - providerId, - query, - options: {'filter': filter, 'limit': limit}, - ); -} - -List _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 = []; - - 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 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 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 >[]; - 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 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 >[]; - 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 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 >[]; - 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(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(builder: builder)); - }); - return; - } - - activeTabNavigator.push(MaterialPageRoute(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 createState() => _ClickableArtistNameState(); -} - -class _ClickableArtistNameState extends State { - List<_ArtistTapTarget> _artistTargets = const []; - final List _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 _buildMultiArtistSpans() { - final spans = []; - 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 = []; - final seen = {}; - 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 _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 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? _pickBestResultByName( - List> results, - String query, -) { - if (results.isEmpty) return null; - - final normalizedQuery = _normalizeForMatch(query); - if (normalizedQuery.isEmpty) return results.first; - - Map? 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'; diff --git a/lib/utils/confirm_and_delete_tracks.dart b/lib/utils/confirm_and_delete_tracks.dart index 78227094..7a1d234b 100644 --- a/lib/utils/confirm_and_delete_tracks.dart +++ b/lib/utils/confirm_and_delete_tracks.dart @@ -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 confirmAndDeleteTracks({ - required BuildContext context, - required List ids, - required Future Function(String id) deleteItem, - Future Function(List ids)? persistDeletedItems, - required VoidCallback onExitSelectionMode, -}) async { - final confirmed = await showAppDialog( - 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 = []; - 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'; diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index d703a292..a02af173 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -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 runExtensionOperationWithVerificationRetry({ - required String extensionId, - required String browserMode, - required Future Function() operation, - Future 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 knownExtensionIds, -) { - final idsByNormalized = { - 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 openPendingExtensionVerification( - String extensionId, { - String browserMode = 'in_app_first', - void Function(Uri authUri)? onAuthUri, - Future? 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? 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 openVerificationAndAwaitGrant( - String extensionId, { - required String browserMode, - Future Function(String extensionId)? awaitForeground, - Future? 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(); - late final StreamSubscription 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 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 _awaitVerificationStepOrCancellation( - Future operation, - Future? cancellationSignal, -) { - if (cancellationSignal == null) { - return operation.then((value) => value); - } - return Future.any([ - operation.then((value) => value), - cancellationSignal.then((_) => null), - ]); -} - -Future showExtensionVerificationHelpDialog( - String extensionId, - Uri authUri, { - String browserMode = 'in_app_first', - bool immediateFailure = false, - Future? 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 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( - 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 _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 launchExtensionAuthUrl(Uri uri, {required String browserMode}) { - return _launchVerificationUrl(uri, browserMode); -} - -Future _launchVerificationUrl( - Uri uri, - String browserMode, { - Future? 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'; diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index a3abc213..6dd1ceb7 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.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'; diff --git a/lib/widgets/track_list_tile.dart b/lib/widgets/track_list_tile.dart index b0804db3..cdeb6f0a 100644 --- a/lib/widgets/track_list_tile.dart +++ b/lib/widgets/track_list_tile.dart @@ -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'; diff --git a/test/extension_action_forms_test.dart b/test/extension_action_forms_test.dart index efdb1fa3..fca11849 100644 --- a/test/extension_action_forms_test.dart +++ b/test/extension_action_forms_test.dart @@ -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';