import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../cache/offline_provider.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()!; // #427 S4: refreshable system playlists need the live build/ // shuffle endpoints — unavailable offline. Disable their play // (use Shuffle all instead). User playlists play from cache. final offline = ref.watch(offlineProvider); final hasTracks = playlist.trackCount > 0 && !(offline && playlist.refreshable); 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(LucideIcons.list_music, color: fs.ash, size: 56) : ServerImage( url: playlist.coverUrl, fit: BoxFit.cover, fallback: Icon(LucideIcons.list_music, color: fs.ash, size: 56), ), ), ), Positioned( bottom: 6, right: 6, child: PlayCircleButton( enabled: hasTracks, onPressed: () => _playPlaylist(context, 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.refreshable) Positioned( top: 2, right: 2, child: PopupMenuButton( icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment, size: 18), tooltip: 'Playlist actions', color: fs.iron, onSelected: (_) => _refresh(context, ref), itemBuilder: (_) => [ PopupMenuItem( value: 'refresh', child: Row(children: [ Icon(LucideIcons.refresh_cw, 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), ), ), ), if (playlist.isSystem && _refreshedLabel(playlist.createdAt) != '') Padding( padding: const EdgeInsets.only(top: 2), child: Text( _refreshedLabel(playlist.createdAt), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.ash, fontSize: 11), ), ), ]), ), ), ), ); } String get _refreshLabel => 'Refresh ${playlist.name}'; /// #417: system playlists atomic-replace on rebuild, so createdAt /// is the last-rotated time. Friendly wording mirrors the web /// PlaylistCard. Empty string when the timestamp is unparseable. String _refreshedLabel(String iso) { final t = DateTime.tryParse(iso); if (t == null) return ''; final now = DateTime.now(); final mins = now.difference(t).inMinutes; if (mins < 5) return 'Refreshed just now'; final startOfToday = DateTime(now.year, now.month, now.day); if (!t.isBefore(startOfToday)) return 'Refreshed today'; final days = startOfToday.difference(DateTime(t.year, t.month, t.day)).inDays; if (days <= 1) return 'Refreshed yesterday'; if (days < 7) return 'Refreshed $days days ago'; const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; return 'Refreshed ${months[t.month - 1]} ${t.day}'; } /// 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. /// /// Failure modes are surfaced via SnackBar so a not-yet-built mix /// or a slow server doesn't look like the tap was ignored. Future _playPlaylist(BuildContext context, WidgetRef ref) async { final messenger = ScaffoldMessenger.of(context); final api = await ref.read(playlistsApiProvider.future); final PlaylistDetail detail; try { // Refreshable (singleton) system playlists: fetch the server's // rotation-aware order (#415) and play as-is, tagged with the // source so the reporter advances rotation. User playlists AND // non-singleton system kinds (songs_like_artist — no by-kind // endpoint) play the stored order via get(), untagged. detail = await (playlist.refreshable ? api.systemShuffle(playlist.systemVariant!) : api.get(playlist.id)) .timeout(const Duration(seconds: 8)); } on TimeoutException { messenger.showSnackBar(const SnackBar( content: Text("Couldn't load playlist — check your connection"), )); return; } catch (e) { messenger.showSnackBar(SnackBar( content: Text('Playlist load failed: $e'), )); return; } 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) { messenger.showSnackBar(const SnackBar( content: Text("Mix isn't ready yet — try again in a moment"), )); return; } // Rotation-aware kinds arrive pre-ordered from the server — play // as-is, tagged so the reporter advances rotation. No client // shuffle. Everything else plays stored order, untagged. await ref.read(playerActionsProvider).playTracks( refs, initialIndex: 0, source: playlist.refreshable ? playlist.systemVariant : null, ); } }