import 'package:flutter/material.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'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; final String? coverUrl; final String? headerVideoUrl; final List 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 createState() => _PlaylistScreenState(); } class _PlaylistScreenState extends ConsumerState with SelectionModeMixin, CollapsingHeaderScrollMixin { List? _fetchedTracks; bool _isLoading = false; String? _error; String? _resolvedPlaylistName; String? _resolvedCoverUrl; String? _resolvedHeaderVideoUrl; List 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 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 _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 = 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 _fetchTracksIfNeeded() async { if (widget.tracks.isNotEmpty || widget.playlistId == null) return; setState(() { _isLoading = true; _error = null; }); try { String playlistId = widget.playlistId!; late final Map 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?; final owner = playlistInfo?['owner'] as Map?; final trackList = result['track_list'] as List? ?? []; final tracks = trackList .map((t) => Track.fromBackendMap(t as Map)) .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 = 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 ? 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), ), ), ), ); } final historyLookups = _tracks .map(historyLookupForTrack) .toList(growable: false); final existingHistoryKeys = ref .watch( downloadHistoryBatchExistsProvider( HistoryBatchLookupRequest(historyLookups), ), ) .maybeWhen(data: (keys) => keys, orElse: () => const {}); 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 ? 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 _loveAll(List tracks) => loveAllTracks(context, ref, tracks); void _downloadAll(BuildContext context) { _downloadTracks(context, _tracks); } Future _downloadTracks(BuildContext context, List tracks) async { await queueTracksSkippingDownloaded( context, ref, tracks, artistNameForPicker: _playlistName, recommendedService: _recommendedDownloadService(), playlistName: _playlistName, resolveDefaultService: false, ); } }