refactor(screens): share album-screen scaffolding and track flows

- SelectionModeMixin + CollapsingHeaderScrollMixin extracted;
  local/downloaded album screens now share AlbumTrackTile,
  AlbumScaffoldBody, DestructiveSelectionButton, HeaderMetaRow,
  and confirmAndDeleteTracks
- track_detail_actions.dart: shared downloadSingleTrack,
  queueTracksSkippingDownloaded, download-all confirm, queued
  snackbar, release-date formatter, list footer, love-all
- TtlCache<T> replaces the copied 10-minute static caches;
  album fetch branches share _applyAlbumMetadata
- playlist error card now uses ErrorCard; formatMegabytes shared
  by queue tab and update dialog
This commit is contained in:
zarzet
2026-07-12 19:19:22 +07:00
parent 4b9853eef7
commit 44de61a06a
19 changed files with 1152 additions and 1478 deletions
+84 -327
View File
@@ -6,9 +6,7 @@ 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/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -17,7 +15,7 @@ import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/utils/ttl_cache.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
@@ -25,30 +23,15 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
import 'package:spotiflac_android/widgets/track_list_tile.dart';
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
static final _cache = TtlCache<List<Track>>(const Duration(minutes: 10));
static List<Track>? get(String albumId) {
final entry = _cache[albumId];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_cache.remove(albumId);
return null;
}
return entry.tracks;
}
static List<Track>? get(String albumId) => _cache.get(albumId);
static void set(String albumId, List<Track> tracks) {
_cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl));
}
}
class _CacheEntry {
final List<Track> tracks;
final DateTime expiresAt;
_CacheEntry(this.tracks, this.expiresAt);
static void set(String albumId, List<Track> tracks) =>
_cache.set(albumId, tracks);
}
class AlbumScreen extends ConsumerStatefulWidget {
@@ -170,21 +153,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
}
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;
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
@@ -195,49 +163,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
'album',
_metadataResourceId(directProviderId),
);
final trackList = metadata['track_list'] as List<dynamic>;
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
_applyAlbumMetadata(
metadata['track_list'] as List<dynamic>,
metadata['album_info'] as Map<String, dynamic>?,
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final headerVideo = albumInfo?['header_video']?.toString();
final headerImage = albumInfo?['header_image']?.toString();
final audioTraits = (albumInfo?['audio_traits'] as List?)
?.map((e) => e.toString())
.toList();
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
? headerVideo
: _headerVideoUrl;
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
? headerImage
: _headerImageUrl;
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
? audioTraits
: _audioTraits;
_isLoading = false;
});
}
return;
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
@@ -246,52 +175,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
throw StateError('Failed to load album metadata from extension');
}
final trackList = result['tracks'] as List<dynamic>;
final albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
_applyAlbumMetadata(
result['tracks'] as List<dynamic>,
result['album'] as Map<String, dynamic>?,
fallbackSource: result,
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final headerVideo =
(albumInfo?['header_video'] ?? result['header_video'])?.toString();
final headerImage =
(albumInfo?['header_image'] ?? result['header_image'])?.toString();
final audioTraits =
((albumInfo?['audio_traits'] ?? result['audio_traits']) as List?)
?.map((e) => e.toString())
.toList();
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
? headerVideo
: _headerVideoUrl;
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
? headerImage
: _headerImageUrl;
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
? audioTraits
: _audioTraits;
_isLoading = false;
});
}
return;
}
} catch (e) {
@@ -304,6 +192,63 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
/// Parses tracks + header metadata from either the direct-provider payload
/// (`albumInfo` only) or the extension payload (`albumInfo` falling back to
/// top-level [fallbackSource] keys), then updates state and the TTL cache.
void _applyAlbumMetadata(
List<dynamic> trackList,
Map<String, dynamic>? albumInfo, {
Map<String, dynamic>? fallbackSource,
}) {
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final headerVideo =
(albumInfo?['header_video'] ?? fallbackSource?['header_video'])
?.toString();
final headerImage =
(albumInfo?['header_image'] ?? fallbackSource?['header_image'])
?.toString();
final audioTraits =
((albumInfo?['audio_traits'] ?? fallbackSource?['audio_traits'])
as List?)
?.map((e) => e.toString())
.toList();
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
? headerVideo
: _headerVideoUrl;
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
? headerImage
: _headerImageUrl;
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
? audioTraits
: _audioTraits;
_isLoading = false;
});
}
}
String? _directMetadataProviderId() {
final providerId = _effectiveMetadataProviderIdFromAlbumId();
return providerId.isEmpty ? null : providerId;
@@ -631,44 +576,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
BuildContext context,
ColorScheme colorScheme,
List<Track> tracks,
) {
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
final totalSeconds = tracks.fold<int>(
0,
(sum, t) => sum + (t.duration > 0 ? t.duration : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final lines = <String>[];
if (releaseDate != null && releaseDate.isNotEmpty) {
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,
),
),
),
],
),
),
);
}
) => buildTrackListFooter(context, colorScheme, tracks);
Widget _buildTrackList(
BuildContext context,
@@ -720,139 +628,24 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
},
);
} else {
final extensionState = ref.read(extensionProvider);
final service = resolveEffectiveDownloadService(
settings.defaultService,
extensionState,
);
if (service.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
);
return;
}
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
downloadSingleTrack(
context,
ref,
track,
recommendedService: _recommendedDownloadService(),
);
}
Future<void> _downloadAll(BuildContext context) async {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
);
if (!context.mounted) return;
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
final isInHistory = 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 (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracksToQueue.length} tracks',
artistName: widget.albumName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
},
);
} else {
final extensionState = ref.read(extensionProvider);
final service = resolveEffectiveDownloadService(
settings.defaultService,
extensionState,
);
if (service.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracksToQueue, service);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}
}
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(
await queueTracksSkippingDownloaded(
context,
).showSnackBar(SnackBar(content: Text(message)));
ref,
tracks,
artistNameForPicker: widget.albumName,
recommendedService: _recommendedDownloadService(),
);
}
Widget _buildLoveAllButton() {
@@ -935,41 +728,5 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
Future<void> _loveAll(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 (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 (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
),
);
}
}
}
Future<void> _loveAll(List<Track> tracks) => loveAllTracks(context, ref, tracks);
}
+13 -21
View File
@@ -15,6 +15,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
import 'package:spotiflac_android/utils/ttl_cache.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen;
@@ -30,18 +31,9 @@ import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
static final _cache = TtlCache<_CacheEntry>(const Duration(minutes: 10));
static _CacheEntry? get(String artistId) {
final entry = _cache[artistId];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_cache.remove(artistId);
return null;
}
return entry;
}
static _CacheEntry? get(String artistId) => _cache.get(artistId);
static void set(
String artistId, {
@@ -52,14 +44,16 @@ class _ArtistCache {
String? headerVideoUrl,
int? monthlyListeners,
}) {
_cache[artistId] = _CacheEntry(
albums: albums,
releases: releases,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
headerVideoUrl: headerVideoUrl,
monthlyListeners: monthlyListeners,
expiresAt: DateTime.now().add(_ttl),
_cache.set(
artistId,
_CacheEntry(
albums: albums,
releases: releases,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
headerVideoUrl: headerVideoUrl,
monthlyListeners: monthlyListeners,
),
);
}
}
@@ -71,7 +65,6 @@ class _CacheEntry {
final String? headerImageUrl;
final String? headerVideoUrl;
final int? monthlyListeners;
final DateTime expiresAt;
_CacheEntry({
required this.albums,
@@ -80,7 +73,6 @@ class _CacheEntry {
this.headerImageUrl,
this.headerVideoUrl,
this.monthlyListeners,
required this.expiresAt,
});
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
/// Shared collapsing-header scroll behavior for album-style screens: owns the
/// [ScrollController] and flips [showTitleInAppBar] once the header has
/// scrolled out of view.
mixin CollapsingHeaderScrollMixin<T extends StatefulWidget> on State<T> {
final ScrollController scrollController = ScrollController();
bool showTitleInAppBar = false;
@override
void initState() {
super.initState();
scrollController.addListener(_onHeaderScroll);
}
@override
void dispose() {
scrollController.removeListener(_onHeaderScroll);
scrollController.dispose();
super.dispose();
}
double calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.sizeOf(context);
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
}
void _onHeaderScroll() {
final expandedHeight = calculateExpandedHeight(context);
final shouldShow =
scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != showTitleInAppBar) {
setState(() => showTitleInAppBar = shouldShow);
}
}
}
+81 -317
View File
@@ -1,7 +1,6 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:share_plus/share_plus.dart';
@@ -10,6 +9,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
@@ -17,9 +17,14 @@ import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/widgets/album_scaffold_body.dart';
import 'package:spotiflac_android/widgets/album_track_tile.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/destructive_selection_button.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/disc_separator_chip.dart';
@@ -42,11 +47,10 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
_DownloadedAlbumScreenState();
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
with
SelectionModeMixin<DownloadedAlbumScreen>,
CollapsingHeaderScrollMixin<DownloadedAlbumScreen> {
bool _embeddedCoverRefreshScheduled = false;
List<DownloadHistoryItem>? _albumTracksSourceCache;
List<DownloadHistoryItem>? _albumTracksCache;
@@ -62,19 +66,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
String get _albumLookupKey =>
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant DownloadedAlbumScreen oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -86,20 +77,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
}
void _onScroll() {
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow =
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.sizeOf(context);
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
}
List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems,
) {
@@ -168,90 +145,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return _sortedDiscNumbersCache ?? const [];
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedIds.add(itemId);
});
}
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
void _toggleSelection(String itemId) {
setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedIds.add(itemId);
}
});
}
void _selectAll(List<DownloadHistoryItem> tracks) {
setState(() {
_selectedIds.addAll(tracks.map((e) => e.id));
});
}
Future<void> _deleteSelected(List<DownloadHistoryItem> currentTracks) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final tracksById = {for (final track in currentTracks) track.id: track};
await confirmAndDeleteTracks(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed == true && mounted) {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final idsToDelete = _selectedIds.toList();
final tracksById = {for (final track in currentTracks) track.id: track};
int deletedCount = 0;
for (final id in idsToDelete) {
ids: selectedIds.toList(),
deleteItem: (id) async {
final item = tracksById[id];
if (item != null) {
try {
await deleteFile(item.filePath);
} catch (_) {}
historyNotifier.removeFromHistory(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
}
}
if (item == null) return false;
try {
await deleteFile(item.filePath);
} catch (_) {}
historyNotifier.removeFromHistory(id);
return true;
},
onExitSelectionMode: exitSelectionMode,
);
}
Future<void> _openFile(
@@ -373,52 +284,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isSelectionMode = false);
});
}
pruneSelection(tracks.map((t) => t.id).toSet());
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
),
],
),
return AlbumScaffoldBody(
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
appBar: _buildAppBar(context, colorScheme, tracks),
trackList: _buildTrackList(context, colorScheme, tracks),
bottomBar: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
bottomInset: bottomInset,
bottomPadding: bottomPadding,
);
}
@@ -448,7 +329,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final expandedHeight = _calculateExpandedHeight(context);
final expandedHeight = calculateExpandedHeight(context);
final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks);
final commonQuality = _getCommonQuality(tracks);
@@ -483,7 +364,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return AlbumDetailHeader(
title: widget.albumName,
expandedHeight: expandedHeight,
showTitleInAppBar: _showTitleInAppBar,
showTitleInAppBar: showTitleInAppBar,
background: background,
blurAndScrimBackground:
embeddedCoverPath != null || widget.coverUrl != null,
@@ -560,23 +441,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return placeholder();
}
Widget _metaWhiteItem(IconData? icon, String label) {
const textStyle = TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
);
if (icon == null) return Text(label, style: textStyle);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: Colors.white),
const SizedBox(width: 4),
Text(label, style: textStyle),
],
);
}
Widget _buildDownloadedHeaderMeta(
BuildContext context,
List<DownloadHistoryItem> tracks,
@@ -588,38 +452,15 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
final totalMinutes = (totalSeconds / 60).round();
final parts = <Widget>[];
void add(Widget w) {
if (parts.isNotEmpty) {
parts.add(
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(
'',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
);
}
parts.add(w);
}
add(
_metaWhiteItem(
null,
context.l10n.downloadedAlbumDownloadedCount(tracks.length),
),
);
if (totalMinutes > 0) add(_metaWhiteItem(null, '$totalMinutes min'));
if (commonQuality != null && commonQuality.isNotEmpty) {
add(_metaWhiteItem(Icons.graphic_eq, commonQuality));
}
return Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4,
children: parts,
return HeaderMetaRow(
items: [
HeaderMetaItem(
context.l10n.downloadedAlbumDownloadedCount(tracks.length),
),
if (totalMinutes > 0) HeaderMetaItem('$totalMinutes min'),
if (commonQuality != null && commonQuality.isNotEmpty)
HeaderMetaItem(commonQuality, icon: Icons.graphic_eq),
],
);
}
@@ -734,84 +575,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
List<DownloadHistoryItem> navigationItems,
int navigationIndex,
) {
final isSelected = _selectedIds.contains(track.id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(
track,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
track.trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
title: Text(
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: _isSelectionMode
? null
: IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () =>
_openFile(track, queueItems: navigationItems),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
),
return AlbumTrackTile(
trackNumber: track.trackNumber,
trackName: track.trackName,
subtitle: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
isSelectionMode: isSelectionMode,
isSelected: selectedIds.contains(track.id),
colorScheme: colorScheme,
onToggleSelection: () => toggleSelection(track.id),
onOpen: () => _navigateToMetadataScreen(
track,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
),
onEnterSelectionMode: () => enterSelectionMode(track.id),
onPlay: () => _openFile(track, queueItems: navigationItems),
);
}
@@ -820,7 +603,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final safUris = <String>[];
final filesToShare = <XFile>[];
for (final id in _selectedIds) {
for (final id in selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final path = item.filePath;
@@ -860,7 +643,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
) {
final tracksById = {for (final t in tracks) t.id: t};
return [
for (final t in _selectedIds.map((id) => tracksById[id]))
for (final t in selectedIds.map((id) => tracksById[id]))
if (t != null) UnifiedLibraryItem.fromDownloadHistory(t),
];
}
@@ -871,18 +654,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
List<DownloadHistoryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length;
final selectedCount = selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return SelectionBottomBar(
selectedCount: selectedCount,
allSelected: allSelected,
onClose: _exitSelectionMode,
onClose: exitSelectionMode,
onToggleSelectAll: () {
if (allSelected) {
_exitSelectionMode();
exitSelectionMode();
} else {
_selectAll(tracks);
selectAll(tracks.map((e) => e.id));
}
},
bottomPadding: bottomPadding,
@@ -908,7 +691,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
context,
ref,
_selectedUnifiedItems(tracks),
onExitSelectionMode: _exitSelectionMode,
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
@@ -920,7 +703,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
? () => runBatchReplayGain(
context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: _exitSelectionMode,
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
@@ -939,29 +722,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0
? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
DestructiveSelectionButton(
count: selectedCount,
colorScheme: colorScheme,
onPressed: () => _deleteSelected(tracks),
),
],
);
+9 -6
View File
@@ -40,6 +40,9 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
part 'home_tab_helpers.dart';
part 'home_tab_widgets.dart';
bool _looksLikeUrlOrSpotifyUri(String text) =>
text.startsWith('http') || text.startsWith('spotify:');
class HomeTab extends ConsumerStatefulWidget {
const HomeTab({super.key});
@override
@@ -576,7 +579,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
if (text.startsWith('http') || text.startsWith('spotify:')) return;
if (_looksLikeUrlOrSpotifyUri(text)) return;
_liveSearchDebounce?.cancel();
_liveSearchDebounce = Timer(_liveSearchDelay, () {
@@ -701,7 +704,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (data?.text != null) {
_urlController.text = data!.text!;
final text = data.text!.trim();
if (text.startsWith('http') || text.startsWith('spotify:')) {
if (_looksLikeUrlOrSpotifyUri(text)) {
_fetchMetadata();
}
}
@@ -733,7 +736,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Future<void> _fetchMetadata() async {
final url = _urlController.text.trim();
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
if (_looksLikeUrlOrSpotifyUri(url)) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
final trackState = ref.read(trackProvider);
if (trackState.error != null && mounted) {
@@ -1260,7 +1263,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final text = _urlController.text.trim();
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
if (_looksLikeUrlOrSpotifyUri(text)) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
@@ -3496,7 +3499,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
void _triggerSearchWithFilter(String? filter) {
final text = _urlController.text.trim();
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
if (_looksLikeUrlOrSpotifyUri(text)) return;
_lastSearchQuery = null;
_performSearch(text, filterOverride: filter);
@@ -3580,7 +3583,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final text = _urlController.text.trim();
if (text.isEmpty) return;
if (text.startsWith('http') || text.startsWith('spotify:')) {
if (_looksLikeUrlOrSpotifyUri(text)) {
_fetchMetadata();
_searchFocusNode.unfocus();
return;
+26 -190
View File
@@ -19,10 +19,20 @@ import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/in_library_badge.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
String? _resolveRawCoverUrl(Track track) {
final rawCover = track.coverUrl?.trim();
if (rawCover != null &&
rawCover.isNotEmpty &&
!rawCover.startsWith('content://')) {
return rawCover;
}
return null;
}
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
final LibraryTracksFolderMode mode;
@@ -83,20 +93,10 @@ class _LibraryTracksFolderScreenState
};
}
String? _resolveRawEntryCoverUrl(CollectionTrackEntry entry) {
final rawCover = entry.track.coverUrl?.trim();
if (rawCover != null &&
rawCover.isNotEmpty &&
!rawCover.startsWith('content://')) {
return rawCover;
}
return null;
}
/// Find the first available cover URL from entries.
String? _firstRawCoverUrl(List<CollectionTrackEntry> entries) {
for (final entry in entries) {
final cover = _resolveRawEntryCoverUrl(entry);
final cover = _resolveRawCoverUrl(entry.track);
if (cover != null && cover.isNotEmpty) {
return cover;
}
@@ -712,142 +712,25 @@ class _LibraryTracksFolderScreenState
}
void _confirmDownloadAll(List<Track> tracks) {
if (tracks.isEmpty) 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(tracks.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext);
_downloadAll(tracks);
},
child: Text(context.l10n.dialogDownload),
),
],
);
},
);
confirmDownloadAllDialog(context, tracks.length, () => _downloadAll(tracks));
}
Future<void> _downloadAll(List<Track> tracks) async {
if (tracks.isEmpty) return;
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
);
if (!mounted) return;
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final playlistName = widget.mode == LibraryTracksFolderMode.playlist
? playlist?.name ?? context.l10n.collectionPlaylist
: null;
final tracksToQueue = <Track>[];
var skippedCount = 0;
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
final isInHistory = 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 (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracksToQueue.length} tracks',
artistName: switch (widget.mode) {
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylist,
},
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
playlistName: playlistName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
skippedCount > 0
? context.l10n.discographySkippedDownloaded(
tracksToQueue.length,
skippedCount,
)
: context.l10n.snackbarAddedTracksToQueue(
tracksToQueue.length,
),
),
),
);
},
);
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracksToQueue,
settings.defaultService,
playlistName: playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
skippedCount > 0
? context.l10n.discographySkippedDownloaded(
tracksToQueue.length,
skippedCount,
)
: context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length),
),
),
);
}
await queueTracksSkippingDownloaded(
context,
ref,
tracks,
artistNameForPicker: switch (widget.mode) {
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylist,
},
playlistName: playlistName,
resolveDefaultService: false,
);
}
void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) {
@@ -1186,16 +1069,6 @@ class _CollectionTrackTile extends ConsumerWidget {
);
}
String? _resolveRawCoverUrl(Track track) {
final rawCover = track.coverUrl?.trim();
if (rawCover != null &&
rawCover.isNotEmpty &&
!rawCover.startsWith('content://')) {
return rawCover;
}
return null;
}
Widget _buildTrackCover(BuildContext context, String coverUrl, double size) {
final colorScheme = Theme.of(context).colorScheme;
Widget placeholder() => Container(
@@ -1217,44 +1090,7 @@ class _CollectionTrackTile extends ConsumerWidget {
}
void _downloadTrack(BuildContext context, WidgetRef ref) {
final track = entry.track;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
},
);
} else {
final extensionState = ref.read(extensionProvider);
final service = resolveEffectiveDownloadService(
settings.defaultService,
extensionState,
);
if (service.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
);
return;
}
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
downloadSingleTrack(context, ref, entry.track);
}
Future<void> _navigateToMetadata(BuildContext context, WidgetRef ref) async {
+103 -333
View File
@@ -1,13 +1,13 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/settings_provider.dart';
import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
@@ -23,7 +23,12 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
import 'package:spotiflac_android/widgets/album_scaffold_body.dart';
import 'package:spotiflac_android/widgets/album_track_tile.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/destructive_selection_button.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/disc_separator_chip.dart';
@@ -47,11 +52,10 @@ class LocalAlbumScreen extends ConsumerStatefulWidget {
ConsumerState<LocalAlbumScreen> createState() => _LocalAlbumScreenState();
}
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
with
SelectionModeMixin<LocalAlbumScreen>,
CollapsingHeaderScrollMixin<LocalAlbumScreen> {
late List<LocalLibraryItem> _sortedTracksCache;
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
@@ -68,7 +72,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_rebuildTrackCaches();
}
@@ -81,27 +84,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow =
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.sizeOf(context);
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
}
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
@@ -135,95 +117,29 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return discMap;
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedIds.add(itemId);
});
}
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
void _toggleSelection(String itemId) {
setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedIds.add(itemId);
}
});
}
void _selectAll(List<LocalLibraryItem> tracks) {
setState(() {
_selectedIds.addAll(tracks.map((e) => e.id));
});
}
Future<void> _deleteSelected(List<LocalLibraryItem> currentTracks) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final tracksById = {for (final track in currentTracks) track.id: track};
final deletedCount = await confirmAndDeleteTracks(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
ids: selectedIds.toList(),
deleteItem: (id) async {
final item = tracksById[id];
if (item == null) return false;
if (!isCueVirtualPath(item.filePath)) {
try {
await deleteFile(item.filePath);
} catch (_) {}
}
await libraryNotifier.removeItem(id);
return true;
},
onExitSelectionMode: exitSelectionMode,
);
if (confirmed == true && mounted) {
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final idsToDelete = _selectedIds.toList();
final tracksById = {for (final track in currentTracks) track.id: track};
int deletedCount = 0;
for (final id in idsToDelete) {
final item = tracksById[id];
if (item != null) {
if (!isCueVirtualPath(item.filePath)) {
try {
await deleteFile(item.filePath);
} catch (_) {}
}
await libraryNotifier.removeItem(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
}
}
if (deletedCount == currentTracks.length && mounted) {
Navigator.pop(context);
}
}
@@ -261,57 +177,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isSelectionMode = false);
});
}
pruneSelection(tracks.map((t) => t.id).toSet());
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
),
],
),
return AlbumScaffoldBody(
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
appBar: _buildAppBar(context, colorScheme),
trackList: _buildTrackList(context, colorScheme, tracks),
bottomBar: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
bottomInset: bottomInset,
bottomPadding: bottomPadding,
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final expandedHeight = _calculateExpandedHeight(context);
final expandedHeight = calculateExpandedHeight(context);
final cacheWidth = coverCacheWidthForViewport(context);
final Widget background = widget.coverPath != null
@@ -335,7 +221,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return AlbumDetailHeader(
title: widget.albumName,
expandedHeight: expandedHeight,
showTitleInAppBar: _showTitleInAppBar,
showTitleInAppBar: showTitleInAppBar,
background: background,
blurAndScrimBackground: widget.coverPath != null,
coverBuilder: (context, coverSize) => widget.coverPath != null
@@ -384,23 +270,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
);
}
Widget _metaWhiteItem(IconData? icon, String label) {
const textStyle = TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
);
if (icon == null) return Text(label, style: textStyle);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: Colors.white),
const SizedBox(width: 4),
Text(label, style: textStyle),
],
);
}
Widget _buildLocalHeaderMeta(BuildContext context) {
final tracks = _sortedTracksCache;
final totalSeconds = tracks.fold<int>(
@@ -408,35 +277,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
(sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final parts = <Widget>[];
void add(Widget w) {
if (parts.isNotEmpty) {
parts.add(
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(
'',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
);
}
parts.add(w);
}
add(_metaWhiteItem(null, context.l10n.queueTrackCount(tracks.length)));
if (totalMinutes > 0) add(_metaWhiteItem(null, '$totalMinutes min'));
final quality = _commonQualityCache;
if (quality != null && quality.isNotEmpty) {
add(_metaWhiteItem(Icons.graphic_eq, quality));
}
return Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4,
children: parts,
return HeaderMetaRow(
items: [
HeaderMetaItem(context.l10n.queueTrackCount(tracks.length)),
if (totalMinutes > 0) HeaderMetaItem('$totalMinutes min'),
if (quality != null && quality.isNotEmpty)
HeaderMetaItem(quality, icon: Icons.graphic_eq),
],
);
}
@@ -531,101 +380,44 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
ColorScheme colorScheme,
LocalLibraryItem track,
) {
final isSelected = _selectedIds.contains(track.id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
return AlbumTrackTile(
trackNumber: track.trackNumber,
trackName: track.trackName,
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _openFile(track),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
track.trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
if (track.format != null) ...[
Text(
'',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
],
),
title: Text(
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
Text(
track.format!.toUpperCase(),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
if (track.format != null) ...[
Text(
'',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text(
track.format!.toUpperCase(),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
],
),
trailing: _isSelectionMode
? null
: IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
),
),
],
],
),
isSelectionMode: isSelectionMode,
isSelected: selectedIds.contains(track.id),
colorScheme: colorScheme,
onToggleSelection: () => toggleSelection(track.id),
onOpen: () => _openFile(track),
onEnterSelectionMode: () => enterSelectionMode(track.id),
onPlay: () => _openFile(track),
);
}
@@ -816,7 +608,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
List<LocalLibraryItem> allTracks,
) {
final tracksById = {for (final t in allTracks) t.id: t};
return _selectedIds
return selectedIds
.map((id) => tracksById[id])
.whereType<LocalLibraryItem>()
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
@@ -944,17 +736,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
setState(() {
_selectedIds.clear();
_isSelectionMode = false;
});
exitSelectionMode();
}
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
for (final id in _selectedIds) {
for (final id in selectedIds) {
final item = tracksById[id];
if (item != null) {
selected.add(item);
@@ -966,7 +755,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
// The bar uses AnimatedPositioned (250ms), so wait for the slide-out.
setState(() => _isSelectionMode = false);
setState(() => isSelectionMode = false);
await Future<void>.delayed(const Duration(milliseconds: 300));
if (!mounted) return;
@@ -977,7 +766,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (selection == null || !mounted) {
// Cancelled — restore selection mode (IDs are still intact).
if (mounted) setState(() => _isSelectionMode = true);
if (mounted) setState(() => isSelectionMode = true);
return;
}
@@ -1038,7 +827,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
_exitSelectionMode();
exitSelectionMode();
if (!mounted) {
return;
@@ -1066,7 +855,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
) {
final tracksById = {for (final t in tracks) t.id: t};
return [
for (final t in _selectedIds.map((id) => tracksById[id]))
for (final t in selectedIds.map((id) => tracksById[id]))
if (t != null) UnifiedLibraryItem.fromLocalLibrary(t),
];
}
@@ -1077,19 +866,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
List<LocalLibraryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length;
final selectedCount = selectedIds.length;
final flacEligibleCount = _selectedFlacEligibleItems(tracks).length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return SelectionBottomBar(
selectedCount: selectedCount,
allSelected: allSelected,
onClose: _exitSelectionMode,
onClose: exitSelectionMode,
onToggleSelectAll: () {
if (allSelected) {
_exitSelectionMode();
exitSelectionMode();
} else {
_selectAll(tracks);
selectAll(tracks.map((e) => e.id));
}
},
bottomPadding: bottomPadding,
@@ -1131,7 +920,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
context,
ref,
_selectedUnifiedItems(tracks),
onExitSelectionMode: _exitSelectionMode,
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
@@ -1146,7 +935,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
? () => runBatchReplayGain(
context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: _exitSelectionMode,
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
@@ -1165,29 +954,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0
? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
DestructiveSelectionButton(
count: selectedCount,
colorScheme: colorScheme,
onPressed: () => _deleteSelected(tracks),
),
],
);
+31 -277
View File
@@ -11,14 +11,13 @@ import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:spotiflac_android/widgets/track_list_tile.dart';
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
@@ -295,66 +294,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
);
}
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;
}
Widget _buildPlaylistFooter(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks;
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>[];
if (releaseDate != null &&
releaseDate.isNotEmpty &&
!releaseDate.startsWith('1970')) {
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,
),
),
),
],
),
),
);
}
Widget _buildPlaylistFooter(BuildContext context, ColorScheme colorScheme) =>
buildTrackListFooter(
context,
colorScheme,
_tracks,
excludeEpochReleaseDate: true,
);
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
if (_isLoading) {
@@ -370,24 +316,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
color: colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
_error!,
style: TextStyle(color: colorScheme.error),
),
),
],
),
),
),
child: ErrorCard(error: _error!, colorScheme: colorScheme),
),
);
}
@@ -464,56 +393,14 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Track track, {
int? playlistPosition,
}) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(
track,
service,
qualityOverride: quality,
playlistName: _playlistName,
playlistPosition: playlistPosition,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
},
);
} else {
final extensionState = ref.read(extensionProvider);
final service = resolveEffectiveDownloadService(
settings.defaultService,
extensionState,
);
if (service.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addToQueue(
track,
service,
playlistName: _playlistName,
playlistPosition: playlistPosition,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
downloadSingleTrack(
context,
ref,
track,
recommendedService: _recommendedDownloadService(),
playlistName: _playlistName,
playlistPosition: playlistPosition,
);
}
Widget _buildCircleButton({
@@ -606,161 +493,28 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
void _confirmDownloadAll(BuildContext context) {
if (_tracks.isEmpty) 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(_tracks.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext);
_downloadAll(context);
},
child: Text(context.l10n.dialogDownload),
),
],
);
},
confirmDownloadAllDialog(
context,
_tracks.length,
() => _downloadAll(context),
);
}
Future<void> _loveAll(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 (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 (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
),
);
}
}
}
Future<void> _loveAll(List<Track> tracks) => loveAllTracks(context, ref, tracks);
void _downloadAll(BuildContext context) {
_downloadTracks(context, _tracks);
}
Future<void> _downloadTracks(BuildContext context, List<Track> tracks) async {
if (tracks.isEmpty) return;
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
);
if (!context.mounted) return;
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
final isInHistory = 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 (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracksToQueue.length} tracks',
artistName: _playlistName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
playlistName: _playlistName,
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
},
);
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracksToQueue,
settings.defaultService,
playlistName: _playlistName,
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}
}
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(
await queueTracksSkippingDownloaded(
context,
).showSnackBar(SnackBar(content: Text(message)));
ref,
tracks,
artistNameForPicker: _playlistName,
recommendedService: _recommendedDownloadService(),
playlistName: _playlistName,
resolveDefaultService: false,
);
}
}
+1 -3
View File
@@ -56,9 +56,7 @@ part 'queue_tab_filter_widgets.dart';
part 'queue_tab_batch_actions.dart';
part 'queue_tab_item_widgets.dart';
String _formatDownloadSizeMB(num bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB';
String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) {
final progress = item.progress.clamp(0.0, 1.0);
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Shared multi-select state for list screens: enter/exit selection mode,
/// toggle individual items, select all, and prune ids that fell out of the
/// current item list. Mix into a `State<T>` alongside the screen's own
/// per-item lookups (e.g. queue_tab, library_folder, artist screens).
mixin SelectionModeMixin<T extends StatefulWidget> on State<T> {
bool isSelectionMode = false;
final Set<String> selectedIds = {};
void enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
isSelectionMode = true;
selectedIds.add(itemId);
});
}
void exitSelectionMode() {
setState(() {
isSelectionMode = false;
selectedIds.clear();
});
}
void toggleSelection(String itemId) {
setState(() {
if (selectedIds.contains(itemId)) {
selectedIds.remove(itemId);
if (selectedIds.isEmpty) {
isSelectionMode = false;
}
} else {
selectedIds.add(itemId);
}
});
}
void selectAll(Iterable<String> ids) {
setState(() {
selectedIds.addAll(ids);
});
}
/// Drops ids no longer present in [validIds]; call from build() with the
/// current item ids. Exits selection mode next frame if that empties it.
void pruneSelection(Set<String> validIds) {
selectedIds.removeWhere((id) => !validIds.contains(id));
if (selectedIds.isEmpty && isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => isSelectionMode = false);
});
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Shows a delete-confirmation dialog, deletes the given [ids] one by one via
/// [deleteItem], then shows a "deleted N tracks" snackbar.
/// [onExitSelectionMode] runs right after the delete loop, matching the
/// screens' original ordering.
///
/// Returns the number of items [deleteItem] reported deleted, or null if the
/// user cancelled the dialog.
Future<int?> confirmAndDeleteTracks({
required BuildContext context,
required List<String> ids,
required Future<bool> Function(String id) deleteItem,
required VoidCallback onExitSelectionMode,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(ids.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
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;
var deletedCount = 0;
for (final id in ids) {
if (await deleteItem(id)) deletedCount++;
}
onExitSelectionMode();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
);
}
return deletedCount;
}
+5
View File
@@ -55,6 +55,11 @@ String? normalizeRemoteHttpUrl(String? value) {
return null;
}
/// Byte count expressed as a plain megabyte number with 1 decimal, e.g. "3.4".
String formatMegabytes(num bytes) {
return (bytes / (1024 * 1024)).toStringAsFixed(1);
}
/// Human-readable byte size: "512 B", "3.4 KB", "12.0 MB", "1.25 GB".
String formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
+27
View File
@@ -0,0 +1,27 @@
/// Simple in-memory cache where each entry expires after [ttl].
class TtlCache<T> {
final Duration ttl;
final Map<String, _TtlEntry<T>> _entries = {};
TtlCache(this.ttl);
T? get(String key) {
final entry = _entries[key];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_entries.remove(key);
return null;
}
return entry.value;
}
void set(String key, T value) {
_entries[key] = _TtlEntry(value, DateTime.now().add(ttl));
}
}
class _TtlEntry<T> {
final T value;
final DateTime expiresAt;
_TtlEntry(this.value, this.expiresAt);
}
+67 -2
View File
@@ -207,7 +207,8 @@ class AlbumDetailHeader extends StatelessWidget {
);
},
),
leading: leading ??
leading:
leading ??
IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
@@ -250,7 +251,11 @@ class AlbumPlayActions extends StatelessWidget {
child: FilledButton.icon(
onPressed: onPlay,
icon: const Icon(Icons.play_arrow, size: 20),
label: Text(playLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
label: Text(
playLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
@@ -277,3 +282,63 @@ class AlbumPlayActions extends StatelessWidget {
);
}
}
/// "•"-joined row of white [HeaderMetaItem]s shown under the header subtitle
/// (track count, duration, quality, ...).
class HeaderMetaRow extends StatelessWidget {
const HeaderMetaRow({super.key, required this.items});
final List<Widget> items;
@override
Widget build(BuildContext context) {
final parts = <Widget>[];
for (final item in items) {
if (parts.isNotEmpty) {
parts.add(
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(
'',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
);
}
parts.add(item);
}
return Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4,
children: parts,
);
}
}
/// Single white meta label (optional leading icon) for [HeaderMetaRow].
class HeaderMetaItem extends StatelessWidget {
const HeaderMetaItem(this.label, {super.key, this.icon});
final String label;
final IconData? icon;
@override
Widget build(BuildContext context) {
const textStyle = TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
);
if (icon == null) return Text(label, style: textStyle);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: Colors.white),
const SizedBox(width: 4),
Text(label, style: textStyle),
],
);
}
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
/// Shared body for album-style screens: a [PopScope] that exits selection
/// mode instead of popping, a scrollable track list, and a bottom-select bar
/// that slides in over it.
class AlbumScaffoldBody extends StatelessWidget {
const AlbumScaffoldBody({
super.key,
required this.scrollController,
required this.isSelectionMode,
required this.onExitSelectionMode,
required this.appBar,
required this.trackList,
required this.bottomBar,
required this.bottomInset,
required this.bottomPadding,
});
final ScrollController scrollController;
final bool isSelectionMode;
final VoidCallback onExitSelectionMode;
final Widget appBar;
final Widget trackList;
final Widget bottomBar;
final double bottomInset;
final double bottomPadding;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && isSelectionMode) {
onExitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: scrollController,
slivers: [
appBar,
trackList,
SliverToBoxAdapter(
child: SizedBox(height: isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: isSelectionMode ? 0 : -(200 + bottomPadding),
child: bottomBar,
),
],
),
),
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
/// Shared track row for album-style screens: track number, selection
/// checkbox, title/subtitle, and a trailing play button that's replaced by
/// the checkbox once selection mode is active.
class AlbumTrackTile extends StatelessWidget {
const AlbumTrackTile({
super.key,
required this.trackNumber,
required this.trackName,
required this.subtitle,
required this.isSelectionMode,
required this.isSelected,
required this.colorScheme,
required this.onToggleSelection,
required this.onOpen,
required this.onEnterSelectionMode,
required this.onPlay,
});
final int? trackNumber;
final String trackName;
final Widget subtitle;
final bool isSelectionMode;
final bool isSelected;
final ColorScheme colorScheme;
final VoidCallback onToggleSelection;
final VoidCallback onOpen;
final VoidCallback onEnterSelectionMode;
final VoidCallback onPlay;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: isSelectionMode ? onToggleSelection : onOpen,
onLongPress: isSelectionMode ? null : onEnterSelectionMode,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
title: Text(
trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: subtitle,
trailing: isSelectionMode
? null
: IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: onPlay,
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
),
),
);
}
}
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Full-width delete action for selection-mode bottom bars: destructive
/// (error) styling once at least one item is selected, disabled/neutral
/// otherwise.
class DestructiveSelectionButton extends StatelessWidget {
const DestructiveSelectionButton({
super.key,
required this.count,
required this.colorScheme,
required this.onPressed,
});
final int count;
final ColorScheme colorScheme;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: count > 0 ? onPressed : null,
icon: const Icon(Icons.delete_outline),
label: Text(
count > 0
? context.l10n.downloadedAlbumDeleteCount(count)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: count > 0
? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: count > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
);
}
}
+345
View File
@@ -0,0 +1,345 @@
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/widgets/download_service_picker.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,
}) {
final settings = ref.read(settingsProvider);
void notifyQueued() {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
if (settings.askQualityBeforeDownload) {
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)));
}
/// 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).
Future<void> queueTracksSkippingDownloaded(
BuildContext context,
WidgetRef ref,
List<Track> tracks, {
required String artistNameForPicker,
String? recommendedService,
String? playlistName,
bool resolveDefaultService = true,
}) async {
if (tracks.isEmpty) return;
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
);
if (!context.mounted) return;
final settings = ref.read(settingsProvider);
final localLibState =
(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 = 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 (settings.askQualityBeforeDownload) {
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,
);
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);
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)),
),
);
}
}
}
+3 -2
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/services/apk_downloader.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
class UpdateDialog extends StatefulWidget {
final UpdateInfo updateInfo;
@@ -63,8 +64,8 @@ class _UpdateDialogState extends State<UpdateDialog> {
if (mounted) {
setState(() {
_progress = total > 0 ? received / total : 0;
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
final receivedMB = formatMegabytes(received);
final totalMB = formatMegabytes(total);
_statusText = '$receivedMB / $totalMB MB';
});
}