mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
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:
+84
-327
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user