Files
SpotiFLAC-Mobile/lib/widgets/track_detail_actions.dart
T

374 lines
12 KiB
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/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<void>(
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<void> queueTracksSkippingDownloaded(
BuildContext context,
WidgetRef ref,
List<Track> 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<String> 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 = <Track>[];
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<Track> tracks, {
bool excludeEpochReleaseDate = false,
}) {
if (tracks.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final releaseDate = tracks.first.releaseDate;
final totalSeconds = tracks.fold<int>(
0,
(sum, t) => sum + (t.duration > 0 ? t.duration : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final lines = <String>[];
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<void> loveAllTracks(
BuildContext context,
WidgetRef ref,
List<Track> 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)),
),
);
}
}
}