Files
SpotiFLAC-Mobile/lib/screens/playlist_screen.dart
T
zarzet 62015a4e2d refactor(screens): migrate remaining screens to shared header and selection components
- album/playlist/library-folder/track-metadata adopt
  CollapsingHeaderScrollMixin; artist/library-folder adopt
  SelectionModeMixin
- HeaderCircleButton and HeaderMetaRow replace per-screen copies
  in album and playlist headers; dead _tallHeader flag removed
- SelectionBottomBar gains optional subtitle labels and backs the
  playlist selection bar in queue tab
2026-07-12 19:38:50 +07:00

457 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/album_detail_header.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/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';
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
final String? coverUrl;
final String? headerVideoUrl;
final List<Track> tracks;
final String? playlistId;
final String? recommendedService;
const PlaylistScreen({
super.key,
required this.playlistName,
this.coverUrl,
this.headerVideoUrl,
required this.tracks,
this.playlistId,
this.recommendedService,
});
@override
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
}
class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
with CollapsingHeaderScrollMixin<PlaylistScreen> {
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;
String? _metadataProviderId(String playlistId) {
final providerId = legacyProviderIdFromResourceId(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 trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList
.map((t) => Track.fromBackendMap(t as Map<String, dynamic>))
.toList();
final headerVideo = playlistInfo?['header_video']?.toString();
setState(() {
_fetchedTracks = tracks;
_resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name'])
?.toString();
_resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images'])
?.toString();
_resolvedHeaderVideoUrl =
(headerVideo != null && headerVideo.isNotEmpty)
? headerVideo
: _resolvedHeaderVideoUrl;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
controller: scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildTrackList(context, colorScheme),
_buildPlaylistFooter(context, colorScheme),
SliverToBoxAdapter(
child: SizedBox(height: 32 + context.navBarBottomInset),
),
],
),
);
}
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,
blurAndScrimBackground: !hasMotion && _coverUrl != null,
coverBuilder: hasMotion
? null
: (context, coverSize) => _coverUrl != null
? CachedCoverImage(
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
placeholder: (_, _) => playlistPlaceholder(),
errorWidget: (_, _, _) => playlistPlaceholder(size: 48),
)
: playlistPlaceholder(size: 48),
meta: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.playlist_play, size: 14, color: Colors.white),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(_tracks.length),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
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),
),
);
}
if (_tracks.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
context.l10n.errorNoTracksFound,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
),
);
}
final historyLookups = _tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = ref
.watch(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = _tracks[index];
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: TrackListTile(
track: track,
isInHistory: isInHistory,
onDownload: () =>
_downloadTrack(context, track, playlistPosition: index + 1),
leading: track.coverUrl != null
? 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,
}) {
downloadSingleTrack(
context,
ref,
track,
recommendedService: _recommendedDownloadService(),
playlistName: _playlistName,
playlistPosition: playlistPosition,
);
}
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 ? Colors.redAccent : Colors.white,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
);
}
Widget _buildDownloadAllCenterButton(BuildContext context) {
return FilledButton.icon(
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
icon: const Icon(Icons.download_rounded, size: 18),
label: Text(
context.l10n.downloadAllCount(_tracks.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
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,
);
}
}