Files
minstrel/flutter_client/lib/playlists/widgets/playlist_card.dart
T
bvandeusen d67c0de596 refactor(playlists): #411 R2 — generic registry-driven system endpoints
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.

Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
  exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
  GET /api/playlists/system/{kind}/shuffle ({kind} = raw
  system_variant). Non-singleton/unknown kind → 404. Deleted
  playlists_{foryou,discover}_refresh.go and the per-kind shuffle
  wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
  the refresh affordance generically without hardcoding kinds.

Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
  systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
  playlist.refreshable; label is "Refresh {name}". Tests reworked;
  obsolete refresh-foryou/discover api tests deleted.

Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
  songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
  detail screen gate kebab/Regenerate/source-tagging on refreshable
  so songs_like_artist plays via get() (no by-kind endpoint).

Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:21:09 -04:00

187 lines
7.5 KiB
Dart

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<FabledSwordTheme>()!;
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.refreshable)
Positioned(
top: 2,
right: 2,
child: PopupMenuButton<String>(
icon: Icon(Icons.more_vert, color: fs.parchment, size: 18),
tooltip: 'Playlist actions',
color: fs.iron,
onSelected: (_) => _refresh(context, ref),
itemBuilder: (_) => [
PopupMenuItem<String>(
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 => 'Refresh ${playlist.name}';
/// 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<void> _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<void> _playPlaylist(WidgetRef ref) async {
final api = await ref.read(playlistsApiProvider.future);
// 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.
final detail = playlist.refreshable
? await api.systemShuffle(playlist.systemVariant!)
: await api.get(playlist.id);
final refs = <TrackRef>[];
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;
// 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,
);
}
}