From d4c6bb3f2d049cf679c36feeefb08c29fd465294 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 1 Jun 2026 22:29:13 -0400
Subject: [PATCH 1/6] feat(web): alphabet rail chases pages on click for
unloaded letters
Operator: prior rail disabled empty buckets, so clicking a letter
like 'Z' did nothing if it wasn't loaded yet. AlphabeticalGrid now
accepts a paginated source and walks pages on click until the
target letter surfaces.
- New props: hasMore, onLoadMore. When hasMore=true, every empty
bucket stays enabled (clicking will chase pages); when hasMore=
false, only populated buckets are clickable.
- jumpTo(bucket): if populated, scrollIntoView. Otherwise loop
onLoadMore + tick() until the bucket appears or no more pages.
Loader2 spinner replaces the letter on the pending button;
cursor:wait + aria-busy. Other rail buttons disable while one is
pending so clicks don't stack.
- Library Artists + Albums pass hasMore + onLoadMore through to the
grid. Existing InfiniteScrollSentinel still handles scroll-driven
loading.
Test: new case asserts rail enables empty buckets when hasMore=true
(the click would trigger onLoadMore).
---
.../lib/components/AlphabeticalGrid.svelte | 74 +++++++++++++++++--
.../lib/components/AlphabeticalGrid.test.ts | 24 +++++-
web/src/routes/library/albums/+page.svelte | 2 +
web/src/routes/library/artists/+page.svelte | 2 +
4 files changed, 93 insertions(+), 9 deletions(-)
diff --git a/web/src/lib/components/AlphabeticalGrid.svelte b/web/src/lib/components/AlphabeticalGrid.svelte
index 667cf54d..39781dc5 100644
--- a/web/src/lib/components/AlphabeticalGrid.svelte
+++ b/web/src/lib/components/AlphabeticalGrid.svelte
@@ -1,14 +1,25 @@
@@ -80,17 +123,28 @@
vertically centered via position:sticky + top:50%. -->
{/if}
@@ -146,4 +200,14 @@
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
cursor: default;
}
+ .rail-btn.pending {
+ color: var(--fs-accent);
+ cursor: wait;
+ }
+ :global(.rail-btn .spin) {
+ animation: spin 1s linear infinite;
+ }
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
diff --git a/web/src/lib/components/AlphabeticalGrid.test.ts b/web/src/lib/components/AlphabeticalGrid.test.ts
index 6edb768e..b7b10a80 100644
--- a/web/src/lib/components/AlphabeticalGrid.test.ts
+++ b/web/src/lib/components/AlphabeticalGrid.test.ts
@@ -23,27 +23,43 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({
type AnyProps = any;
describe('AlphabeticalGrid', () => {
- test('rail renders the full #/A-Z/& set; empty buckets are disabled', () => {
+ test('rail renders the full #/A-Z/& set; empty buckets are disabled when no more data could load', () => {
render(AlphabeticalGrid, {
props: {
items,
getKey: (it: Item) => it.key,
item: itemSnippet
+ // hasMore default = false → empty buckets stay disabled.
} as AnyProps
});
// Populated buckets are enabled with 'Jump to ' label.
expect(screen.getByRole('button', { name: 'Jump to A' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Jump to B' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Jump to D' })).not.toBeDisabled();
- // Empty buckets still render but are disabled and labelled
- // 'X — no entries' so screen readers announce the empty state.
+ // Empty buckets disabled when nothing more could load. aria-label
+ // changes too so screen readers announce the empty state.
expect(screen.getByRole('button', { name: 'C — no entries' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Z — no entries' })).toBeDisabled();
- // # (numbers) and & (symbols) always present too.
expect(screen.getByRole('button', { name: 'Numbers — no entries' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Symbols — no entries' })).toBeDisabled();
});
+ test('with hasMore=true, empty buckets are enabled (clicking would trigger onLoadMore)', () => {
+ render(AlphabeticalGrid, {
+ props: {
+ items,
+ getKey: (it: Item) => it.key,
+ item: itemSnippet,
+ hasMore: true,
+ onLoadMore: () => Promise.resolve()
+ } as AnyProps
+ });
+ // Even with no C/Z items loaded, the buttons remain enabled so a
+ // click can chase pages until the letter surfaces.
+ expect(screen.getByRole('button', { name: 'Jump to C' })).not.toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Jump to Z' })).not.toBeDisabled();
+ });
+
test('renders items in given order followed by the jump rail', () => {
const { container } = render(AlphabeticalGrid, {
props: {
diff --git a/web/src/routes/library/albums/+page.svelte b/web/src/routes/library/albums/+page.svelte
index 53bdd960..9c0eb881 100644
--- a/web/src/routes/library/albums/+page.svelte
+++ b/web/src/routes/library/albums/+page.svelte
@@ -73,6 +73,8 @@
a.sort_title || a.title}
+ hasMore={!!query.hasNextPage}
+ onLoadMore={() => query.fetchNextPage()}
>
{#snippet item(album: AlbumRef)}
diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte
index fe36e085..edaf6287 100644
--- a/web/src/routes/library/artists/+page.svelte
+++ b/web/src/routes/library/artists/+page.svelte
@@ -71,6 +71,8 @@
a.sort_name || a.name}
+ hasMore={!!query.hasNextPage}
+ onLoadMore={() => query.fetchNextPage()}
>
{#snippet item(a: ArtistRef)}
--
2.52.0
From 74bae74f9fee6f8ae0af7252fa7057a89fc200ac Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 1 Jun 2026 22:39:24 -0400
Subject: [PATCH 2/6] fix(web): songs-like play overlay + scrubber smoothing
PlaylistCard: per-artist system variants (songs_like_artist) all share
one variant tag but exist as one playlist per seed artist; routing
their play handler through systemShuffle(variant) hit the wrong
playlist (or 404). Detect via seed_artist_id != null and fall through
to getPlaylist(playlist.id) for those; still tag the queue with the
variant so source attribution stays correct.
smoothPosition.svelte.ts: new useSmoothPosition() hook mirrors
Android's rememberSmoothPositionMs. player.position only updates
~4 Hz (HTML audio timeupdate), so the seek thumb stepped visibly;
$effect resets on each canonical tick / seek / track-change and a
rAF loop extrapolates at playback rate between ticks.
Wired into both PlayerBar.svelte (mini + expanded seek rows) and
now-playing/+page.svelte. Seek input handler still reads the raw
range value (not smoothed.value) so user drags stay authoritative.
---
web/src/lib/components/PlayerBar.svelte | 21 ++++++---
web/src/lib/components/PlaylistCard.svelte | 15 +++++-
web/src/lib/player/smoothPosition.svelte.ts | 51 +++++++++++++++++++++
web/src/routes/now-playing/+page.svelte | 9 +++-
4 files changed, 86 insertions(+), 10 deletions(-)
create mode 100644 web/src/lib/player/smoothPosition.svelte.ts
diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte
index fc9bdc0a..30dc8ac4 100644
--- a/web/src/lib/components/PlayerBar.svelte
+++ b/web/src/lib/components/PlayerBar.svelte
@@ -14,11 +14,17 @@
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
+ import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
const current = $derived(player.current);
+ // Per-frame interpolated playhead so the scrubber bar moves
+ // smoothly between server ticks instead of stepping every ~250ms.
+ // Reset on track/play-state change via the helper's $effect.
+ const smoothed = useSmoothPosition();
+
const skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
const skipNextDisabled = $derived(
player.index === player.queue.length - 1 && player.repeat === 'off'
@@ -226,10 +232,12 @@
- {formatDuration(player.position)}
+ {formatDuration(smoothed.value)}
diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte
index 40948f51..3f871edf 100644
--- a/web/src/lib/components/PlaylistCard.svelte
+++ b/web/src/lib/components/PlaylistCard.svelte
@@ -64,7 +64,15 @@
starting = true;
try {
const variant = playlist.system_variant;
- if (variant != null) {
+ // systemShuffle keys by variant alone, which only works for
+ // SINGLE-instance variants (for_you, discover, deep_cuts, …).
+ // songs_like_artist has one playlist PER seed artist; using
+ // the variant alone would return the wrong one (or fail).
+ // For those, fall through to getPlaylist so the play handler
+ // hits the exact playlist the user clicked on. Detect via
+ // seed_artist_id which is non-null only for per-artist mixes.
+ const isPerArtist = playlist.seed_artist_id != null;
+ if (variant != null && !isPerArtist) {
// #415: server returns the rotation-aware order (unplayed
// this rotation first). Play it as-is — no client shuffle —
// and tag the queue so play_started carries `source` and
@@ -78,7 +86,10 @@
const detail = await getPlaylist(playlist.id);
const refs = toTrackRefs(detail.tracks);
if (refs.length > 0) {
- playQueue(refs, 0);
+ // Tag the queue with the variant when one exists so play
+ // events still carry the correct source attribution even
+ // though we went through the per-id path.
+ playQueue(refs, 0, variant != null ? { source: variant } : undefined);
}
}
} finally {
diff --git a/web/src/lib/player/smoothPosition.svelte.ts b/web/src/lib/player/smoothPosition.svelte.ts
new file mode 100644
index 00000000..7ba471cf
--- /dev/null
+++ b/web/src/lib/player/smoothPosition.svelte.ts
@@ -0,0 +1,51 @@
+import { player } from './store.svelte';
+
+/**
+ * Reactive smoothed playhead position in seconds. Mirrors Android's
+ * `rememberSmoothPositionMs`: the underlying `player.position` updates
+ * every ~250 ms (HTML audio `timeupdate` fires ~4 Hz), so reading it
+ * directly snaps the scrubber forward in discrete steps. This helper
+ * extrapolates per-frame between server ticks so the bar glides at
+ * playback rate.
+ *
+ * The `$effect` re-runs whenever the canonical position / isPlaying /
+ * duration changes, which both handles natural ticks (re-sync to the
+ * new authoritative value) and seeks (instant jump on user action).
+ * The rAF loop advances at wall-clock seconds since the segment
+ * started, so as long as the canonical position keeps up with real
+ * time the resync on each tick is sub-frame and invisible.
+ *
+ * Returns a `{ value }` accessor instead of a bare number so callers
+ * can read it reactively inside components (Svelte's rune wiring
+ * follows the property access).
+ */
+export function useSmoothPosition(): { readonly value: number } {
+ let displayed = $state(player.position);
+
+ $effect(() => {
+ // Reset whenever the canonical position changes (server tick,
+ // seek, track change) so we anchor to the truth.
+ displayed = player.position;
+
+ if (!player.isPlaying || player.duration <= 0) return;
+
+ const startReal = performance.now();
+ const startPos = player.position;
+ const duration = player.duration;
+
+ let rafId = 0;
+ const tick = () => {
+ const elapsed = (performance.now() - startReal) / 1000;
+ displayed = Math.min(startPos + elapsed, duration);
+ rafId = requestAnimationFrame(tick);
+ };
+ rafId = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(rafId);
+ });
+
+ return {
+ get value() {
+ return displayed;
+ }
+ };
+}
diff --git a/web/src/routes/now-playing/+page.svelte b/web/src/routes/now-playing/+page.svelte
index 54ccd4a1..ad963b81 100644
--- a/web/src/routes/now-playing/+page.svelte
+++ b/web/src/routes/now-playing/+page.svelte
@@ -14,11 +14,16 @@
} from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
+ import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
import LikeButton from '$lib/components/LikeButton.svelte';
import { pageTitle } from '$lib/branding';
const current = $derived(player.current);
+ // Per-frame interpolated playhead so the scrubber thumb glides
+ // smoothly between server ticks. Shares the helper with PlayerBar.
+ const smoothed = useSmoothPosition();
+
const skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
const skipNextDisabled = $derived(
player.index === player.queue.length - 1 && player.repeat === 'off'
@@ -123,13 +128,13 @@
min="0"
max={player.duration || 0}
step="0.1"
- value={player.position}
+ value={smoothed.value}
oninput={onSeekInput}
aria-label="Seek"
class="w-full accent-accent"
/>
--
2.52.0
From b77a7121cabe2fe9819acbfa4ab4287a399f760b Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 1 Jun 2026 22:40:35 -0400
Subject: [PATCH 3/6] feat(android): restyle NowPlaying scrubber thumb to match
web client
User feedback: dislikes both the old M3 20dp thumb and the current
10dp slim variant; wants the bare HTML range thumb the web client
shows. Replace SliderDefaults.Thumb with a plain Box(CircleShape +
accent fill, 14dp). Drops the M3 state-layer halo on press and the
implicit elevation/border so the on-screen result matches a
rendering. Slider's 48dp hit
slop is intrinsic to the composable, so tapability is unchanged.
---
.../minstrel/player/ui/NowPlayingScreen.kt | 21 ++++++++++---------
1 file changed, 11 insertions(+), 10 deletions(-)
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt
index 96f24442..46a74516 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -31,7 +32,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
-import androidx.compose.ui.unit.DpSize
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.graphics.Brush
@@ -458,16 +458,17 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
modifier = Modifier.fillMaxWidth(),
colors = sliderColors,
interactionSource = interactionSource,
- // Slim 10dp thumb shrinks the scrubber's visual weight. M3
- // default is 20dp; halving keeps it tappable (Slider's own
- // 48dp hit slop is unchanged) but lets it recede into the UI.
- // Track left at default — the colour pin alone already
- // matches Flutter's slate look.
+ // Plain filled circle to match the web client's `` thumb — flat, no state-layer
+ // ring, no border. M3's SliderDefaults.Thumb paints a state
+ // layer halo on press; we drop it for visual parity. Slider's
+ // 48dp hit slop still applies, so tapability is unchanged.
thumb = {
- SliderDefaults.Thumb(
- interactionSource = interactionSource,
- colors = sliderColors,
- thumbSize = DpSize(10.dp, 10.dp),
+ Box(
+ modifier = Modifier
+ .size(14.dp)
+ .clip(CircleShape)
+ .background(accent),
)
},
)
--
2.52.0
From 15a545a25cfe68b4642dd00cdaac4fd306344a32 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 1 Jun 2026 23:24:06 -0400
Subject: [PATCH 4/6] feat(web): two-pane now-playing on desktop with permanent
queue panel
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The full player previously sat dead-center on wide viewports — cover
+ controls capped at max-w-md left the right two-thirds of the screen
empty while the queue remained behind a drawer toggle. Split the
layout at lg+:
- Extract QueueList from QueueDrawer (header + count + scrollable
list body + empty state); the drawer now wraps QueueList for its
slide-in chrome, and the now-playing route embeds it directly.
- now-playing: at lg+ render the player as a left section + a 384px
(xl: 448px) aside with QueueList. Below lg the layout is unchanged
single-column and the drawer toggle in the bottom row still opens
it (lg:hidden on the toggle button keeps it out of the way once
the panel is permanent).
- QueueList owns the close X conditionally — drawer passes onClose
and the bind:closeButtonRef for focus management; embedded panel
omits both since it has no dismiss action.
QueueTrackRow's drag-to-reorder, click-to-jump, and current-row
highlight all carry over for free since they're owned by the row
component.
---
web/src/lib/components/QueueDrawer.svelte | 41 ++--------------
web/src/lib/components/QueueList.svelte | 57 +++++++++++++++++++++++
web/src/routes/now-playing/+page.svelte | 19 +++++++-
3 files changed, 77 insertions(+), 40 deletions(-)
create mode 100644 web/src/lib/components/QueueList.svelte
diff --git a/web/src/lib/components/QueueDrawer.svelte b/web/src/lib/components/QueueDrawer.svelte
index 924b3f26..075c52f4 100644
--- a/web/src/lib/components/QueueDrawer.svelte
+++ b/web/src/lib/components/QueueDrawer.svelte
@@ -1,18 +1,10 @@
+
+
+ {:else}
+ {#each player.queue as track, i (track.id)}
+
+ {/each}
+ {/if}
+
+
diff --git a/web/src/routes/now-playing/+page.svelte b/web/src/routes/now-playing/+page.svelte
index ad963b81..03aa9841 100644
--- a/web/src/routes/now-playing/+page.svelte
+++ b/web/src/routes/now-playing/+page.svelte
@@ -16,6 +16,7 @@
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
import LikeButton from '$lib/components/LikeButton.svelte';
+ import QueueList from '$lib/components/QueueList.svelte';
import { pageTitle } from '$lib/branding';
const current = $derived(player.current);
@@ -89,7 +90,13 @@
{:else}
-
+
+
+
{player.queue.length}
+
+
{/if}
--
2.52.0
From 6a7d9afdbc81c44d80d88f1d0d9c8cefa560b848 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 1 Jun 2026 23:29:36 -0400
Subject: [PATCH 5/6] fix(android): latch NowPlaying drag-dismiss to prevent
back-stack underflow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Operator reproduced black-screen-on-resume by drag-down dismissing
the full player. Root cause: the NestedScrollConnection accumulator
crossed the dismiss threshold, called navController.popBackStack(),
reset accumulated to 0 — but the user's finger was still down and
the pop transition was still running. The next frame's onPostScroll
re-accumulated and re-fired onDismiss(), popping the screen BENEATH
NowPlaying. When that left the back stack empty the NavHost had no
destination to draw, producing a black window until the process
was killed and the activity was cold-launched.
Add a `dismissed` latch that survives until the connection is
disposed (which only happens when NowPlayingScreen leaves the
composition, i.e. the pop completes). After the latch sets we
consume the remaining drag (return `available`) so the underlying
scrollable doesn't paint over-scroll while the pop transitions.
onPreFling also bails after dismissal so the fling can't restart
the accumulator.
---
.../minstrel/player/ui/NowPlayingScreen.kt | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt
index 46a74516..3ceea516 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt
@@ -189,16 +189,29 @@ private fun rememberDragDismissConnection(
object : NestedScrollConnection {
private var accumulated = 0f
+ // One-shot latch. popBackStack is async — between the first
+ // dismissal and the screen actually leaving the composition,
+ // the user's finger is still down and more onPostScroll
+ // frames arrive. Without this guard the accumulator rebuilds
+ // and onDismiss() fires a second time, popping the screen
+ // BENEATH NowPlaying. If that leaves the back stack empty
+ // the NavHost renders nothing → black screen on resume.
+ private var dismissed = false
+
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
+ // After dismissal, eat all remaining drag so the
+ // scrollable doesn't paint over-scroll deltas during the
+ // pop transition.
+ if (dismissed) return available
if (source != NestedScrollSource.UserInput) return Offset.Zero
return if (available.y > 0f) {
accumulated += available.y
if (accumulated >= thresholdPx) {
- accumulated = 0f
+ dismissed = true
onDismiss()
}
Offset(0f, available.y)
@@ -209,6 +222,7 @@ private fun rememberDragDismissConnection(
}
override suspend fun onPreFling(available: Velocity): Velocity {
+ if (dismissed) return available
accumulated = 0f
return Velocity.Zero
}
--
2.52.0
From 6ac36dd334680ac280da65f2786b5eb73308c5a7 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 1 Jun 2026 23:31:45 -0400
Subject: [PATCH 6/6] fix(web): user-playlist play omits third arg to satisfy
contract test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
74bae74f routed per-artist system mixes through getPlaylist and
always passed the source attribution as the third arg, falling
back to undefined for true user playlists. Vitest's toHaveBeenCalledWith
is arity-strict — playQueue(refs, 0, undefined) is not the same as
playQueue(refs, 0) — so the PlaylistCard contract test failed on
the user-playlist case.
Split the call: pass three args only when a variant tag exists,
two args for user playlists. Preserves source attribution for
songs_like_artist and keeps user playlists source-less as the
test pins.
---
web/src/lib/components/PlaylistCard.svelte | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte
index 3f871edf..84e358c0 100644
--- a/web/src/lib/components/PlaylistCard.svelte
+++ b/web/src/lib/components/PlaylistCard.svelte
@@ -86,10 +86,17 @@
const detail = await getPlaylist(playlist.id);
const refs = toTrackRefs(detail.tracks);
if (refs.length > 0) {
- // Tag the queue with the variant when one exists so play
- // events still carry the correct source attribution even
- // though we went through the per-id path.
- playQueue(refs, 0, variant != null ? { source: variant } : undefined);
+ // Per-artist system mixes (songs_like_artist) carry a
+ // variant tag so play_started still attributes the source
+ // correctly even though we routed through the per-id path.
+ // True user playlists (variant == null) call with two args
+ // so source attribution stays absent — the PlaylistCard
+ // test pins this contract.
+ if (variant != null) {
+ playQueue(refs, 0, { source: variant });
+ } else {
+ playQueue(refs, 0);
+ }
}
}
} finally {
--
2.52.0