mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-15 22:35:36 +02:00
Share the retained history lookup snapshot across albums, playlists, artists, home search results, and library folders. Reuse derived lookups until the track list is replaced. Test reuse with 4,000 items and invalidation after replacement, sorting, and filtering.
549 lines
18 KiB
Dart
549 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:spotiflac_android/screens/track_history_snapshot.dart';
|
|
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
|
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
|
|
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
|
|
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
|
import 'package:spotiflac_android/widgets/selection_action_button.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.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/utils/image_cache_utils.dart';
|
|
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
|
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
|
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
|
import 'package:spotiflac_android/utils/provider_resource_ids.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/screens/collapsing_header_scroll_mixin.dart';
|
|
import 'package:spotiflac_android/widgets/error_card.dart';
|
|
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
|
|
|
|
class PlaylistScreen extends ConsumerStatefulWidget {
|
|
final String playlistName;
|
|
final String? coverUrl;
|
|
final String? headerVideoUrl;
|
|
final List<Track> tracks;
|
|
final String? playlistId;
|
|
final String? metadataProviderId;
|
|
final String? recommendedService;
|
|
|
|
const PlaylistScreen({
|
|
super.key,
|
|
required this.playlistName,
|
|
this.coverUrl,
|
|
this.headerVideoUrl,
|
|
required this.tracks,
|
|
this.playlistId,
|
|
this.metadataProviderId,
|
|
this.recommendedService,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
|
|
}
|
|
|
|
class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
|
|
with
|
|
SelectionModeMixin<PlaylistScreen>,
|
|
CollapsingHeaderScrollMixin<PlaylistScreen> {
|
|
final _historySnapshot = TrackHistorySnapshot();
|
|
List<Track>? _fetchedTracks;
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
String? _resolvedPlaylistName;
|
|
String? _resolvedCoverUrl;
|
|
String? _resolvedHeaderVideoUrl;
|
|
|
|
List<Track> get _tracks => _fetchedTracks ?? widget.tracks;
|
|
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
|
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
|
|
String? get _headerVideoUrl =>
|
|
_resolvedHeaderVideoUrl ?? widget.headerVideoUrl;
|
|
|
|
/// Playlists can legitimately contain the same track twice, so selection is
|
|
/// keyed by position as well as id (matching the album screen).
|
|
String _trackSelectionId(Track track, int index) => '${track.id}#$index';
|
|
|
|
List<Track> get _selectedTracks => [
|
|
for (var index = 0; index < _tracks.length; index++)
|
|
if (selectedIds.contains(_trackSelectionId(_tracks[index], index)))
|
|
_tracks[index],
|
|
];
|
|
|
|
Widget _buildSelectionBottomBar(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
double bottomPadding,
|
|
) {
|
|
final selectedCount = selectedIds.length;
|
|
final allSelected = _tracks.isNotEmpty && selectedCount == _tracks.length;
|
|
|
|
return SelectionBottomBar(
|
|
selectedCount: selectedCount,
|
|
allSelected: allSelected,
|
|
onClose: exitSelectionMode,
|
|
onToggleSelectAll: () {
|
|
if (allSelected) {
|
|
exitSelectionMode();
|
|
} else {
|
|
selectAll([
|
|
for (var index = 0; index < _tracks.length; index++)
|
|
_trackSelectionId(_tracks[index], index),
|
|
]);
|
|
}
|
|
},
|
|
bottomPadding: bottomPadding,
|
|
children: [
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: SelectionActionButton(
|
|
icon: Icons.download_rounded,
|
|
label: '${context.l10n.dialogDownload} ($selectedCount)',
|
|
onPressed: selectedCount == 0 ? null : _downloadSelected,
|
|
colorScheme: colorScheme,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _downloadSelected() async {
|
|
final selected = _selectedTracks;
|
|
if (selected.isEmpty) return;
|
|
await queueTracksSkippingDownloaded(
|
|
context,
|
|
ref,
|
|
selected,
|
|
artistNameForPicker: _playlistName,
|
|
recommendedService: widget.recommendedService,
|
|
forceQualityPicker: true,
|
|
onQueued: exitSelectionMode,
|
|
);
|
|
}
|
|
|
|
String? _metadataProviderId(String playlistId) {
|
|
final providerId = resolvePreferredMetadataProviderId(
|
|
widget.metadataProviderId,
|
|
playlistId,
|
|
);
|
|
if (providerId == null) return null;
|
|
final effective = resolveEffectiveMetadataProvider(
|
|
providerId,
|
|
ref.read(extensionProvider),
|
|
);
|
|
return effective.isEmpty ? null : effective;
|
|
}
|
|
|
|
String _metadataResourceId(String providerId, String playlistId) {
|
|
return stripPrefixedResourceId(playlistId);
|
|
}
|
|
|
|
String? _recommendedDownloadService() {
|
|
final explicit = widget.recommendedService;
|
|
if (explicit != null && explicit.isNotEmpty) {
|
|
return explicit;
|
|
}
|
|
|
|
final playlistId = widget.playlistId;
|
|
if (playlistId != null) {
|
|
final providerId = _metadataProviderId(playlistId);
|
|
if (providerId != null && providerId.isNotEmpty) {
|
|
return resolveEffectiveDownloadService(
|
|
providerId,
|
|
ref.read(extensionProvider),
|
|
);
|
|
}
|
|
}
|
|
|
|
final source = _tracks.firstOrNull?.source;
|
|
if (source != null && source.isNotEmpty) {
|
|
return source;
|
|
}
|
|
|
|
final trackId = _tracks.firstOrNull?.id ?? '';
|
|
final trackProviderId = legacyProviderIdFromResourceId(trackId);
|
|
if (trackProviderId != null) {
|
|
return resolveEffectiveDownloadService(
|
|
trackProviderId,
|
|
ref.read(extensionProvider),
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_fetchTracksIfNeeded();
|
|
}
|
|
|
|
Future<void> _fetchTracksIfNeeded() async {
|
|
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
String playlistId = widget.playlistId!;
|
|
late final Map<String, dynamic> result;
|
|
final providerId = _metadataProviderId(playlistId);
|
|
if (providerId != null) {
|
|
result = await PlatformBridge.getProviderMetadata(
|
|
providerId,
|
|
'playlist',
|
|
_metadataResourceId(providerId, playlistId),
|
|
);
|
|
} else {
|
|
result = await PlatformBridge.getProviderMetadata(
|
|
'deezer',
|
|
'playlist',
|
|
playlistId,
|
|
);
|
|
}
|
|
if (!mounted) return;
|
|
|
|
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
|
|
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
|
|
final resolvedPlaylistName =
|
|
(playlistInfo?['name'] ?? owner?['name'])?.toString() ??
|
|
_playlistName;
|
|
|
|
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
|
final tracks = trackList
|
|
.map(
|
|
(t) => Track.fromBackendMap(
|
|
t as Map<String, dynamic>,
|
|
playlistName: resolvedPlaylistName,
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
final headerVideo = playlistInfo?['header_video']?.toString();
|
|
|
|
setState(() {
|
|
_fetchedTracks = tracks;
|
|
_resolvedPlaylistName = resolvedPlaylistName;
|
|
_resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images'])
|
|
?.toString();
|
|
_resolvedHeaderVideoUrl =
|
|
(headerVideo != null && headerVideo.isNotEmpty)
|
|
? headerVideo
|
|
: _resolvedHeaderVideoUrl;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = context.friendlyError(e);
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final bottomPadding = MediaQuery.paddingOf(context).bottom;
|
|
|
|
pruneSelection({
|
|
for (var index = 0; index < _tracks.length; index++)
|
|
_trackSelectionId(_tracks[index], index),
|
|
});
|
|
|
|
return CollectionScaffold(
|
|
scrollController: scrollController,
|
|
isSelectionMode: isSelectionMode,
|
|
onExitSelectionMode: exitSelectionMode,
|
|
bottomInset: context.navBarBottomInset,
|
|
selectionBar: _buildSelectionBottomBar(
|
|
context,
|
|
colorScheme,
|
|
bottomPadding,
|
|
),
|
|
appBar: _buildAppBar(context, colorScheme),
|
|
slivers: [
|
|
_buildTrackList(context, colorScheme),
|
|
_buildPlaylistFooter(context, colorScheme),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
|
final expandedHeight = calculateExpandedHeight(context);
|
|
final cacheWidth = coverCacheWidthForViewport(context);
|
|
final motionUrl = _headerVideoUrl;
|
|
final hasMotion =
|
|
motionUrl != null &&
|
|
motionUrl.trim().isNotEmpty &&
|
|
Uri.tryParse(motionUrl)?.hasAuthority == true;
|
|
Widget playlistPlaceholder({double? size}) {
|
|
return Container(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.playlist_play,
|
|
size: size ?? 80,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
}
|
|
|
|
final Widget coverImage = _coverUrl != null
|
|
? CachedCoverImage(
|
|
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: cacheWidth,
|
|
placeholder: (_, _) => Container(color: colorScheme.surface),
|
|
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
|
)
|
|
: playlistPlaceholder();
|
|
|
|
return AlbumDetailHeader(
|
|
title: _playlistName,
|
|
expandedHeight: expandedHeight,
|
|
showTitleInAppBar: showTitleInAppBar,
|
|
background: hasMotion
|
|
? MotionHeaderBanner(videoUrl: motionUrl, fallback: coverImage)
|
|
: coverImage,
|
|
paletteSource: _coverUrl,
|
|
blurAndScrimBackground: !hasMotion && _coverUrl != null,
|
|
coverBuilder: hasMotion
|
|
? null
|
|
: (context, coverSize) => _coverUrl != null
|
|
? DownloadableCover(
|
|
coverUrl: highResCoverUrl(_coverUrl) ?? _coverUrl,
|
|
baseName: _playlistName,
|
|
child: CachedCoverImage(
|
|
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: cacheWidth,
|
|
placeholder: (_, _) => playlistPlaceholder(),
|
|
errorWidget: (_, _, _) => playlistPlaceholder(size: 48),
|
|
),
|
|
)
|
|
: playlistPlaceholder(size: 48),
|
|
meta: HeaderMetaRow(
|
|
items: [
|
|
HeaderMetaItem(
|
|
context.l10n.tracksCount(_tracks.length),
|
|
icon: Icons.playlist_play,
|
|
),
|
|
],
|
|
),
|
|
actions: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildLoveAllButton(),
|
|
const SizedBox(width: 12),
|
|
Flexible(child: _buildDownloadAllCenterButton(context)),
|
|
const SizedBox(width: 12),
|
|
_buildAddToPlaylistButton(context),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlaylistFooter(BuildContext context, ColorScheme colorScheme) =>
|
|
buildTrackListFooter(
|
|
context,
|
|
colorScheme,
|
|
_tracks,
|
|
excludeEpochReleaseDate: true,
|
|
);
|
|
|
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
|
|
if (_isLoading) {
|
|
return const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: TrackListSkeleton(itemCount: 8),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_error != null) {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: ErrorCard(
|
|
error: _error!,
|
|
colorScheme: colorScheme,
|
|
onRetry: _fetchTracksIfNeeded,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_tracks.isEmpty) {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Center(
|
|
child: Text(
|
|
context.l10n.errorNoTracksFound,
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
_historySnapshot.update(_tracks);
|
|
final historyLookups = _historySnapshot.lookups;
|
|
final existingHistoryKeys = ref.watch(
|
|
downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request),
|
|
);
|
|
return SliverPadding(
|
|
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
|
sliver: SliverList(
|
|
delegate: SliverChildBuilderDelegate((context, index) {
|
|
final track = _tracks[index];
|
|
final selectionId = _trackSelectionId(track, index);
|
|
final isInHistory = existingHistoryKeys.contains(
|
|
historyLookups[index].lookupKey,
|
|
);
|
|
return KeyedSubtree(
|
|
key: ValueKey(track.id),
|
|
child: StaggeredListItem(
|
|
index: index,
|
|
child: TrackListTile(
|
|
track: track,
|
|
isInHistory: isInHistory,
|
|
isSelectionMode: isSelectionMode,
|
|
isSelected: selectedIds.contains(selectionId),
|
|
onToggleSelection: () => toggleSelection(selectionId),
|
|
onEnterSelectionMode: () => enterSelectionMode(selectionId),
|
|
onDownload: ({bool forceQualityPicker = false}) =>
|
|
_downloadTrack(
|
|
context,
|
|
track,
|
|
playlistPosition: index + 1,
|
|
forceQualityPicker: forceQualityPicker,
|
|
),
|
|
leading: track.coverUrl != null
|
|
? DownloadableCover(
|
|
coverUrl: track.coverUrl,
|
|
baseName: '${track.artistName} - ${track.name}',
|
|
enabled: !isSelectionMode,
|
|
child: CachedCoverImage(
|
|
imageUrl: track.coverUrl!,
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
)
|
|
: Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Icons.music_note,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}, childCount: _tracks.length),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _downloadTrack(
|
|
BuildContext context,
|
|
Track track, {
|
|
int? playlistPosition,
|
|
bool forceQualityPicker = false,
|
|
}) {
|
|
downloadSingleTrack(
|
|
context,
|
|
ref,
|
|
track,
|
|
recommendedService: _recommendedDownloadService(),
|
|
playlistName: _playlistName,
|
|
playlistPosition: playlistPosition,
|
|
forceQualityPicker: forceQualityPicker,
|
|
);
|
|
}
|
|
|
|
Widget _buildLoveAllButton() {
|
|
final collectionsState = ref.watch(libraryCollectionsProvider);
|
|
final allLoved =
|
|
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
|
|
|
|
return HeaderCircleButton(
|
|
icon: allLoved ? Icons.favorite : Icons.favorite_border,
|
|
iconColor: allLoved ? Theme.of(context).colorScheme.error : null,
|
|
tooltip: allLoved
|
|
? context.l10n.trackOptionRemoveFromLoved
|
|
: context.l10n.tooltipLoveAll,
|
|
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
|
|
);
|
|
}
|
|
|
|
Widget _buildDownloadAllCenterButton(BuildContext context) {
|
|
return HeaderFilledButton(
|
|
icon: Icons.download_rounded,
|
|
label: context.l10n.downloadAllCount(_tracks.length),
|
|
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildAddToPlaylistButton(BuildContext context) {
|
|
return HeaderCircleButton(
|
|
icon: Icons.playlist_add,
|
|
tooltip: context.l10n.tooltipAddToPlaylist,
|
|
onPressed: _tracks.isEmpty
|
|
? null
|
|
: () => showAddTracksToPlaylistSheet(
|
|
context,
|
|
ref,
|
|
_tracks,
|
|
playlistNamePrefill: widget.playlistName,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDownloadAll(BuildContext context) {
|
|
confirmDownloadAllDialog(
|
|
context,
|
|
_tracks.length,
|
|
() => _downloadAll(context),
|
|
);
|
|
}
|
|
|
|
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 {
|
|
await queueTracksSkippingDownloaded(
|
|
context,
|
|
ref,
|
|
tracks,
|
|
artistNameForPicker: _playlistName,
|
|
recommendedService: _recommendedDownloadService(),
|
|
playlistName: _playlistName,
|
|
resolveDefaultService: false,
|
|
);
|
|
}
|
|
}
|