6df02428b0
Tapping a system-playlist PlayCircleButton before the mix had loaded
silently stalled — three failure modes all looked identical to the
user (PlayCircleButton's spinner clears with no playback):
- api.systemShuffle returns empty (mix not built server-side yet)
→ silent return at `if (refs.isEmpty)`
- api call slow / hung → spinner spins indefinitely (no client
timeout was set; Dio default is unlimited)
- api throws → uncaught; spinner finalizer clears it silently
Bundle:
- 8s `.timeout` on the systemShuffle / get call; TimeoutException
→ "Couldn't load playlist — check your connection"
- empty refs after filtering → "Mix isn't ready yet — try again
in a moment"
- other throws → "Playlist load failed: <error>"
- thread BuildContext through and capture ScaffoldMessenger before
the first await so no `use_build_context_synchronously` lint
No pre-warm — system playlists are intentionally uncached per the
api endpoint comment ("varies per play"); pre-warming would decide
the shuffle order at home-screen load instead of at tap, breaking
the fresh-per-play contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
10 KiB
Dart
250 lines
10 KiB
Dart
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<FabledSwordTheme>()!;
|
|
// #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<String>(
|
|
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment, size: 18),
|
|
tooltip: 'Playlist actions',
|
|
color: fs.iron,
|
|
onSelected: (_) => _refresh(context, ref),
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem<String>(
|
|
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<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.
|
|
///
|
|
/// 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<void> _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 = <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) {
|
|
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,
|
|
);
|
|
}
|
|
}
|