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)}
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 @@
+
+