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..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 @@ -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 @@ -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 } @@ -458,16 +472,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), ) }, ) 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/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)} @@ -346,10 +354,11 @@
- +
- {formatDuration(player.position)} + {formatDuration(smoothed.value)} diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index 40948f51..84e358c0 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,17 @@ const detail = await getPlaylist(playlist.id); const refs = toTrackRefs(detail.tracks); if (refs.length > 0) { - playQueue(refs, 0); + // 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 { 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 @@ + +
+
+
+

Queue

+

+ {player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'} + {#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if} +

+
+ {#if onClose} + + {/if} +
+ +
+ {#if player.queue.length === 0} +

No tracks queued.

+ {:else} + {#each player.queue as track, i (track.id)} + + {/each} + {/if} +
+
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/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)} diff --git a/web/src/routes/now-playing/+page.svelte b/web/src/routes/now-playing/+page.svelte index 54ccd4a1..03aa9841 100644 --- a/web/src/routes/now-playing/+page.svelte +++ b/web/src/routes/now-playing/+page.svelte @@ -14,11 +14,17 @@ } 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 QueueList from '$lib/components/QueueList.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' @@ -84,7 +90,13 @@

{:else} -
+ +
+
- {formatDuration(player.position)} + {formatDuration(smoothed.value)} {formatDuration(player.duration)}
@@ -230,12 +242,20 @@ aria-label="Open queue" class="flex items-center gap-1.5 rounded px-3 py-2 min-h-[44px] text-text-secondary hover:text-text-primary - focus-visible:ring-2 focus-visible:ring-accent" + focus-visible:ring-2 focus-visible:ring-accent + lg:hidden" > {player.queue.length} +
+
{/if}