feat(playlists): system playlists default to shuffle on tile play

Closes Fable #412 (For You force-refresh on play) and #413
(shuffle-on-play default for system playlists).

Web (PlaylistCard.svelte + player/store.svelte.ts):
- Drop the refreshForYou() call from the play handler. The daily
  03:00 user-local snapshot is what plays now. Stops burning server
  compute on every press and stops swapping the playlist out from
  under the user.
- Generalize the kebab affordance to render for any system playlist
  (was Discover-only). Adds "Refresh For You" as an explicit
  replacement so users can still force a regen when they want one.
- Extend playQueue(tracks, startIndex, { shuffle? }) to Fisher-Yates
  the queue when shuffle:true. PlaylistCard passes shuffle:true for
  any non-null system_variant.

Flutter (player_provider.dart + playlists/widgets/playlist_card.dart):
- playTracks now accepts shuffle:bool. When true, picks a random
  starting index and enables AudioServiceShuffleMode.all after
  setQueueFromTracks. PlaylistCard passes shuffle:playlist.isSystem.

User playlists keep linear order. Detail-screen play buttons are
unchanged for now (follow-up if user requests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:55:57 -04:00
parent 33b11a3b3d
commit d12afdad6e
5 changed files with 124 additions and 83 deletions
+24 -23
View File
@@ -14,6 +14,11 @@
const isOwn = $derived(user.value?.id === playlist.user_id);
const isDiscover = $derived(playlist.system_variant === 'discover');
const isForYou = $derived(playlist.system_variant === 'for_you');
const isSystem = $derived(playlist.system_variant != null);
const refreshLabel = $derived(
isDiscover ? 'Refresh Discover' : isForYou ? 'Refresh For You' : 'Refresh',
);
const queryClient = useQueryClient();
@@ -40,38 +45,34 @@
if (starting) return;
starting = true;
try {
let detailID = playlist.id;
// For-You: refresh first so click means "give me a fresh mix
// and play it." Atomic replace creates a new playlist row, so
// the response's playlist_id is what we follow up with.
if (playlist.system_variant === 'for_you') {
const refreshed = await refreshForYou();
if (!refreshed.playlist_id) {
// Empty library — nothing to play.
return;
}
detailID = refreshed.playlist_id;
// Invalidate caches so the home + detail views see the new
// playlist on their next read.
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
}
const detail = await getPlaylist(detailID);
const detail = await getPlaylist(playlist.id);
const refs = toTrackRefs(detail.tracks);
if (refs.length > 0) {
playQueue(refs, 0);
// System playlists default to shuffle so replaying within a
// day feels different. User playlists keep stored order.
// Explicit refresh moved to the kebab menu — playing no
// longer rebuilds the snapshot.
playQueue(refs, 0, { shuffle: playlist.system_variant != null });
}
} finally {
starting = false;
}
}
async function onRefreshDiscover(e: MouseEvent) {
async function onRefresh(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
menuOpen = false;
try {
await refreshDiscover();
pushToast('Discover refreshed.');
if (isDiscover) {
await refreshDiscover();
pushToast('Discover refreshed.');
} else if (isForYou) {
await refreshForYou();
pushToast('For You refreshed.');
} else {
return;
}
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlist.id) });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
} catch (err: unknown) {
@@ -83,7 +84,7 @@
<svelte:window onclick={() => { menuOpen = false; }} />
<div class="card relative">
{#if isDiscover}
{#if isSystem}
<div class="kebab-wrap absolute right-1 top-1 z-10">
<button
type="button"
@@ -108,11 +109,11 @@
<button
type="button"
role="menuitem"
onclick={onRefreshDiscover}
onclick={onRefresh}
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-text-primary hover:bg-surface-hover"
>
<RefreshCcw size={14} strokeWidth={1} />
Refresh Discover
{refreshLabel}
</button>
</div>
{/if}