Files
SpotiFLAC-Mobile/lib/widgets/track_detail_actions.dart
T
zarzet 63e1a9b9c8 feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
2026-09-19 12:10:10 +07:00

376 lines
12 KiB
Dart

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/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) {
// A context menu may be disposed before the quality picker is submitted.
final container = ProviderScope.containerOf(context, listen: false);
DownloadServicePicker.show(
context,
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: recommendedService,
onSelect: (quality, service) {
container
.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;
showAppDialog<void>(
context: context,
builder: (dialogContext) {
return AppAlertDialog(
title: Text(context.l10n.dialogDownloadAllTitle),
content: Text(context.l10n.dialogDownloadAllMessage(trackCount)),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
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)),
),
);
}
}
}