feat(web/playlists): stale-view banner + Refresh on an open system-playlist after rebuild
test-web / test (push) Successful in 39s
test-web / test (push) Successful in 39s
#980. When the daily rebuild fires while a system-playlist detail page is open, its cached data goes stale and can't be refetched in place — the playlist id rotated, so the old id 404s. serverEvents now exposes a monotonic rebuild counter; the detail page shows a "this mix was refreshed" banner with a Refresh that re-resolves the variant (systemShuffle) to the new playlist id and navigates there. No forced redirect, no auto-reload — the user refreshes on their terms. Functional behaviors were already correct (tapping a song plays it; tiles load the current mix); this closes the cosmetic list-staleness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,20 @@ import { user } from '$lib/auth/store.svelte';
|
|||||||
// deliberately do NOT yank a playing queue here — that would interrupt a
|
// deliberately do NOT yank a playing queue here — that would interrupt a
|
||||||
// mid-song listen to restart the new mix at track 0.
|
// mid-song listen to restart the new mix at track 0.
|
||||||
|
|
||||||
|
// Monotonic rebuild counter (#980). Bumped on every playlist.system_rebuilt.
|
||||||
|
// An open system-playlist DETAIL page can't be invalidated in place (its id
|
||||||
|
// rotates on rebuild — a refetch would 404), so instead it watches this
|
||||||
|
// counter and offers a "this mix was refreshed → Refresh" affordance that
|
||||||
|
// re-resolves the variant to the new playlist.
|
||||||
|
let _systemRebuildCount = $state(0);
|
||||||
|
export const systemRebuilt = {
|
||||||
|
get count(): number {
|
||||||
|
return _systemRebuildCount;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function onSystemRebuilt(): void {
|
function onSystemRebuilt(): void {
|
||||||
|
_systemRebuildCount++;
|
||||||
queryClient.invalidateQueries({ queryKey: qk.home() });
|
queryClient.invalidateQueries({ queryKey: qk.home() });
|
||||||
// Prefix match invalidates every kind ('user' | 'system' | 'all').
|
// Prefix match invalidates every kind ('user' | 'system' | 'all').
|
||||||
queryClient.invalidateQueries({ queryKey: ['playlists'] });
|
queryClient.invalidateQueries({ queryKey: ['playlists'] });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
|
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -12,13 +13,15 @@
|
|||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
removePlaylistTrack,
|
removePlaylistTrack,
|
||||||
reorderPlaylist,
|
reorderPlaylist,
|
||||||
refreshSystem
|
refreshSystem,
|
||||||
|
systemShuffle
|
||||||
} from '$lib/api/playlists';
|
} from '$lib/api/playlists';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '$lib/auth/store.svelte';
|
||||||
import { errCode, errMessage } from '$lib/api/errors';
|
import { errCode, errMessage } from '$lib/api/errors';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
||||||
|
import { systemRebuilt } from '$lib/serverEvents.svelte';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -174,6 +177,40 @@
|
|||||||
refreshingSystem = false;
|
refreshingSystem = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- #980: stale-view banner for an open system-playlist detail page ---
|
||||||
|
// The daily rebuild rotates the playlist id, so this page's cached data goes
|
||||||
|
// stale and can't be refetched in place (the old id 404s). Watch the global
|
||||||
|
// rebuild counter; when it advances while we're on a system playlist, offer a
|
||||||
|
// Refresh that re-resolves the variant to the fresh playlist.
|
||||||
|
const isSystemPlaylist = $derived(playlistQuery?.data?.system_variant != null);
|
||||||
|
// Baseline starts at the current count so a rebuild earlier this session
|
||||||
|
// doesn't flash the banner on open; re-synced on navigation below.
|
||||||
|
let acknowledgedRebuild = $state(systemRebuilt.count);
|
||||||
|
$effect(() => {
|
||||||
|
id; // re-sync the baseline whenever we land on a different playlist
|
||||||
|
acknowledgedRebuild = untrack(() => systemRebuilt.count);
|
||||||
|
});
|
||||||
|
const staleSystemView = $derived(
|
||||||
|
isSystemPlaylist && systemRebuilt.count > acknowledgedRebuild
|
||||||
|
);
|
||||||
|
|
||||||
|
let reloadingStale = $state(false);
|
||||||
|
async function onReloadStale() {
|
||||||
|
const variant = playlistQuery?.data?.system_variant;
|
||||||
|
if (!variant) return;
|
||||||
|
reloadingStale = true;
|
||||||
|
try {
|
||||||
|
// The rebuild minted a new playlist id for this variant; resolve it and
|
||||||
|
// navigate there so the page loads the fresh mix.
|
||||||
|
const fresh = await systemShuffle(variant);
|
||||||
|
await goto(`/playlists/${fresh.id}`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(`Couldn’t load the refreshed mix: ${errCode(e)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
reloadingStale = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -187,6 +224,24 @@
|
|||||||
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
|
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
|
||||||
{:else if playlistQuery?.data}
|
{:else if playlistQuery?.data}
|
||||||
{@const pl = playlistQuery.data}
|
{@const pl = playlistQuery.data}
|
||||||
|
{#if staleSystemView}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
class="mb-4 flex items-center justify-between gap-3 rounded-md border border-accent/40
|
||||||
|
bg-accent/10 px-4 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-text-primary">This mix was refreshed since you opened it.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onReloadStale}
|
||||||
|
disabled={reloadingStale}
|
||||||
|
class="flex-shrink-0 rounded-md bg-action-secondary px-3 py-1 text-xs font-medium
|
||||||
|
text-action-fg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reloadingStale ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if !editing}
|
{#if !editing}
|
||||||
<header class="mb-6 flex items-start gap-4">
|
<header class="mb-6 flex items-start gap-4">
|
||||||
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-hover">
|
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-hover">
|
||||||
|
|||||||
Reference in New Issue
Block a user