import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../library/widgets/play_circle_button.dart'; import '../../models/playlist.dart'; import '../../models/track.dart'; import '../../player/player_provider.dart'; import '../../shared/widgets/server_image.dart'; import '../../theme/theme_extension.dart'; import '../playlists_provider.dart'; /// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp), /// name + optional system-variant badge below. Tap pushes /// `/playlists/{id}`. The bottom-right play button fetches the /// playlist and starts playback from track 0; disabled when the /// playlist has no tracks. class PlaylistCard extends ConsumerWidget { const PlaylistCard({super.key, required this.playlist}); final Playlist playlist; @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final hasTracks = playlist.trackCount > 0; return SizedBox( width: 176, child: Material( color: Colors.transparent, child: InkWell( onTap: () => context.push('/playlists/${playlist.id}', extra: playlist), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // Stack: collage + overlaid play button at bottom-right. Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(6), child: Container( width: 144, height: 144, color: fs.slate, // coverUrl is deterministic per playlist (see // CachedPlaylistAdapter.toRef). When the server's // collage isn't built yet, ServerImage's // errorBuilder shows the queue_music icon over the // slate background. child: playlist.coverUrl.isEmpty ? Icon(Icons.queue_music, color: fs.ash, size: 56) : ServerImage( url: playlist.coverUrl, fit: BoxFit.cover, fallback: Icon(Icons.queue_music, color: fs.ash, size: 56), ), ), ), Positioned( bottom: 6, right: 6, child: PlayCircleButton( enabled: hasTracks, onPressed: () => _playPlaylist(ref), ), ), // System playlists get a refresh affordance so the // user can force a fresh mix instead of waiting for // the daily 03:00 rebuild. Mirrors the web kebab. if (playlist.isSystem) Positioned( top: 2, right: 2, child: PopupMenuButton( icon: Icon(Icons.more_vert, color: fs.parchment, size: 18), tooltip: 'Playlist actions', color: fs.iron, onSelected: (_) => _refresh(context, ref), itemBuilder: (_) => [ PopupMenuItem( value: 'refresh', child: Row(children: [ Icon(Icons.refresh, color: fs.parchment, size: 18), const SizedBox(width: 8), Text(_refreshLabel, style: TextStyle(color: fs.parchment)), ]), ), ], ), ), ], ), const SizedBox(height: 8), Text( playlist.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.parchment, fontSize: 14), ), if (playlist.isSystem) Padding( padding: const EdgeInsets.only(top: 2), child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: fs.iron, borderRadius: BorderRadius.circular(4), ), child: Text( playlist.systemVariant!.replaceAll('_', ' '), style: TextStyle(color: fs.accent, fontSize: 11), ), ), ), ]), ), ), ), ); } String get _refreshLabel => switch (playlist.systemVariant) { 'for_you' => 'Refresh For You', 'discover' => 'Refresh Discover', _ => 'Refresh', }; /// Forces a server-side rebuild of this system playlist, then /// invalidates the aggregate list so the rotated UUID + new tracks /// land on the next read. ScaffoldMessenger is captured before the /// await so we don't touch a stale BuildContext afterward. Future _refresh(BuildContext context, WidgetRef ref) async { final messenger = ScaffoldMessenger.of(context); try { final api = await ref.read(playlistsApiProvider.future); await api.refreshSystem(playlist.systemVariant!); ref.invalidate(playlistsListProvider); messenger.showSnackBar( SnackBar(content: Text('${playlist.name} refreshed')), ); } catch (e) { messenger.showSnackBar( SnackBar(content: Text("Couldn't refresh: $e")), ); } } /// Fetches the playlist via /api/playlists/{id}, materializes each /// PlaylistTrack into a TrackRef (filtering out unavailable rows /// whose `trackId` is null after a track-delete), and plays from /// index 0. Mirrors the web PlaylistCard's onPlayClick. Future _playPlaylist(WidgetRef ref) async { final api = await ref.read(playlistsApiProvider.future); // System playlists: fetch the server's rotation-aware order // (#415) and play it as-is, tagged with the source so the // play-events reporter advances that playlist's rotation. User // playlists keep stored order, untagged. final detail = playlist.isSystem ? await api.systemShuffle(playlist.systemVariant!) : await api.get(playlist.id); final refs = []; for (final t in detail.tracks) { if (t.trackId == null) continue; refs.add(TrackRef( id: t.trackId!, title: t.title, albumId: t.albumId ?? '', albumTitle: t.albumTitle, artistId: t.artistId ?? '', artistName: t.artistName, durationSec: t.durationSec, streamUrl: t.streamUrl ?? '', )); } if (refs.isEmpty) return; // System playlists already arrive in rotation-aware order from // the server (#415) — play as-is, tagged with the variant so the // reporter advances rotation. No client shuffle. User playlists // play in stored order, untagged. await ref.read(playerActionsProvider).playTracks( refs, initialIndex: 0, source: playlist.isSystem ? playlist.systemVariant : null, ); } }