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/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; /// Shared single-track "add to queue" flow for detail screens: shows the /// quality/service picker when the user opted into it, otherwise resolves /// the default service and enqueues directly. void downloadSingleTrack( BuildContext context, WidgetRef ref, Track track, { String? recommendedService, String? playlistName, int? playlistPosition, bool forceQualityPicker = false, }) { final settings = ref.read(settingsProvider); void notifyQueued() { showAddedToQueueSnackBar(context, track.name); } if (settings.askQualityBeforeDownload || forceQualityPicker) { DownloadServicePicker.show( context, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, recommendedService: recommendedService, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) .addToQueue( track, service, qualityOverride: quality, playlistName: playlistName, playlistPosition: playlistPosition, ); notifyQueued(); }, ); } else { final extensionState = ref.read(extensionProvider); final service = resolveEffectiveDownloadService( settings.defaultService, extensionState, ); if (service.isEmpty) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), ); return; } ref .read(downloadQueueProvider.notifier) .addToQueue( track, service, playlistName: playlistName, playlistPosition: playlistPosition, ); notifyQueued(); } } /// Shared "download all" confirmation dialog for detail screens /// (album/playlist/library-folder). void confirmDownloadAllDialog( BuildContext context, int trackCount, VoidCallback onConfirm, ) { if (trackCount == 0) return; showDialog( context: context, builder: (dialogContext) { final colorScheme = Theme.of(dialogContext).colorScheme; return AlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, title: Text(context.l10n.dialogDownloadAllTitle), content: Text(context.l10n.dialogDownloadAllMessage(trackCount)), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.dialogCancel), ), FilledButton( onPressed: () { Navigator.pop(dialogContext); onConfirm(); }, child: Text(context.l10n.dialogDownload), ), ], ); }, ); } /// Shared "added N / skipped N" snackbar shown after queueing a batch of /// tracks from album/playlist/library-folder detail screens. void showQueuedSnackbar(BuildContext context, int added, int skipped) { if (!context.mounted) return; final message = skipped > 0 ? context.l10n.discographySkippedDownloaded(added, skipped) : context.l10n.snackbarAddedTracksToQueue(added); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } bool shouldShowBatchDownloadPicker({ required bool forceQualityPicker, required bool askQualityBeforeDownload, required bool allowQualityVariants, }) => forceQualityPicker || askQualityBeforeDownload || allowQualityVariants; /// Shared batch "add to queue" flow for detail screens: skips tracks already /// present in download history or the local library, then either shows the /// quality/service picker or resolves a default service, before enqueueing /// the rest. /// /// Set [resolveDefaultService] to false to match screens that pass /// `settings.defaultService` straight through without resolving it against /// enabled extensions (playlist / library-folder screens do this today). /// [forceQualityPicker] lets an explicit multi-select action choose one /// provider and quality for the complete batch. [onQueued] runs only after /// tracks are actually added, not when the picker is merely opened. Future queueTracksSkippingDownloaded( BuildContext context, WidgetRef ref, List tracks, { required String artistNameForPicker, String? recommendedService, String? playlistName, bool resolveDefaultService = true, bool forceQualityPicker = false, VoidCallback? onQueued, }) async { if (tracks.isEmpty) return; final settings = ref.read(settingsProvider); final skipExisting = settings.deduplicateDownloads && !settings.allowQualityVariants; final historyLookups = tracks .map(historyLookupForTrack) .toList(growable: false); Set existingHistoryKeys = const {}; if (skipExisting) { try { existingHistoryKeys = await ref.read( downloadHistoryBatchExistsProvider( HistoryBatchLookupRequest(historyLookups), ).future, ); } catch (e) { // Queueing without the skip beats silently doing nothing: several // callers fire this flow unawaited, so a throw here would vanish. AppLogger('DownloadFlow').w('Duplicate check failed, queueing all: $e'); } } if (!context.mounted) return; final localLibState = (skipExisting && settings.localLibraryEnabled && settings.localLibraryShowDuplicates) ? ref.read(localLibraryProvider) : null; final tracksToQueue = []; var skippedCount = 0; for (var i = 0; i < tracks.length; i++) { final track = tracks[i]; final isInHistory = skipExisting && existingHistoryKeys.contains(historyLookups[i].lookupKey); final isInLocal = localLibState?.existsInLibrary( isrc: track.isrc, trackName: track.name, artistName: track.artistName, ) ?? false; if (isInHistory || isInLocal) { skippedCount++; } else { tracksToQueue.add(track); } } if (tracksToQueue.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( context.l10n.discographySkippedDownloaded(0, skippedCount), ), ), ); return; } if (shouldShowBatchDownloadPicker( forceQualityPicker: forceQualityPicker, askQualityBeforeDownload: settings.askQualityBeforeDownload, allowQualityVariants: settings.allowQualityVariants, )) { DownloadServicePicker.show( context, trackName: '${tracksToQueue.length} tracks', artistName: artistNameForPicker, recommendedService: recommendedService, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) .addMultipleToQueue( tracksToQueue, service, qualityOverride: quality, playlistName: playlistName, ); onQueued?.call(); showQueuedSnackbar(context, tracksToQueue.length, skippedCount); }, ); return; } String service; if (resolveDefaultService) { final extensionState = ref.read(extensionProvider); service = resolveEffectiveDownloadService( settings.defaultService, extensionState, ); if (service.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), ); return; } } else { service = settings.defaultService; } ref .read(downloadQueueProvider.notifier) .addMultipleToQueue(tracksToQueue, service, playlistName: playlistName); onQueued?.call(); showQueuedSnackbar(context, tracksToQueue.length, skippedCount); } /// "dd/MM/yyyy" or "MM/yyyy" formatting for a backend release date string. String formatReleaseDate(String date) { if (date.length >= 10) { final parts = date.substring(0, 10).split('-'); if (parts.length == 3) { return '${parts[2]}/${parts[1]}/${parts[0]}'; } } else if (date.length >= 7) { final parts = date.split('-'); if (parts.length >= 2) { return '${parts[1]}/${parts[0]}'; } } return date; } /// Shared release-date/track-count/duration footer for album/playlist detail /// screens. Set [excludeEpochReleaseDate] to hide a `1970-...` placeholder /// release date (playlists can carry one; albums never do). Widget buildTrackListFooter( BuildContext context, ColorScheme colorScheme, List tracks, { bool excludeEpochReleaseDate = false, }) { if (tracks.isEmpty) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } final releaseDate = tracks.first.releaseDate; final totalSeconds = tracks.fold( 0, (sum, t) => sum + (t.duration > 0 ? t.duration : 0), ); final totalMinutes = (totalSeconds / 60).round(); final lines = []; final hasReleaseDate = releaseDate != null && releaseDate.isNotEmpty && (!excludeEpochReleaseDate || !releaseDate.startsWith('1970')); if (hasReleaseDate) { lines.add(formatReleaseDate(releaseDate)); } final countText = context.l10n.tracksCount(tracks.length); lines.add(totalMinutes > 0 ? '$countText • $totalMinutes min' : countText); return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final line in lines) Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( line, style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: 13, fontWeight: FontWeight.w500, ), ), ), ], ), ), ); } /// Shared "love all / unlove all" batch toggle for album/playlist detail /// screens. Future loveAllTracks( BuildContext context, WidgetRef ref, List tracks, ) async { final notifier = ref.read(libraryCollectionsProvider.notifier); final state = ref.read(libraryCollectionsProvider); final allLoved = tracks.every((t) => state.isLoved(t)); if (allLoved) { for (final track in tracks) { final key = trackCollectionKey(track); await notifier.removeFromLoved(key); } if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( context.l10n.snackbarRemovedTracksFromLoved(tracks.length), ), ), ); } } else { int addedCount = 0; for (final track in tracks) { if (!state.isLoved(track)) { await notifier.toggleLoved(track); addedCount++; } } if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)), ), ); } } }