mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-03 01:28:37 +02:00
374 lines
12 KiB
Dart
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)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|