Alphabet rail page-chasing + Songs Like fix + scrubber polish #70
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -31,7 +32,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.ui.unit.DpSize
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
@@ -189,16 +189,29 @@ private fun rememberDragDismissConnection(
|
|||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
private var accumulated = 0f
|
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(
|
override fun onPostScroll(
|
||||||
consumed: Offset,
|
consumed: Offset,
|
||||||
available: Offset,
|
available: Offset,
|
||||||
source: NestedScrollSource,
|
source: NestedScrollSource,
|
||||||
): Offset {
|
): 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
|
if (source != NestedScrollSource.UserInput) return Offset.Zero
|
||||||
return if (available.y > 0f) {
|
return if (available.y > 0f) {
|
||||||
accumulated += available.y
|
accumulated += available.y
|
||||||
if (accumulated >= thresholdPx) {
|
if (accumulated >= thresholdPx) {
|
||||||
accumulated = 0f
|
dismissed = true
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
Offset(0f, available.y)
|
Offset(0f, available.y)
|
||||||
@@ -209,6 +222,7 @@ private fun rememberDragDismissConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||||
|
if (dismissed) return available
|
||||||
accumulated = 0f
|
accumulated = 0f
|
||||||
return Velocity.Zero
|
return Velocity.Zero
|
||||||
}
|
}
|
||||||
@@ -458,16 +472,17 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = sliderColors,
|
colors = sliderColors,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
// Slim 10dp thumb shrinks the scrubber's visual weight. M3
|
// Plain filled circle to match the web client's `<input
|
||||||
// default is 20dp; halving keeps it tappable (Slider's own
|
// type="range" accent-color>` thumb — flat, no state-layer
|
||||||
// 48dp hit slop is unchanged) but lets it recede into the UI.
|
// ring, no border. M3's SliderDefaults.Thumb paints a state
|
||||||
// Track left at default — the colour pin alone already
|
// layer halo on press; we drop it for visual parity. Slider's
|
||||||
// matches Flutter's slate look.
|
// 48dp hit slop still applies, so tapability is unchanged.
|
||||||
thumb = {
|
thumb = {
|
||||||
SliderDefaults.Thumb(
|
Box(
|
||||||
interactionSource = interactionSource,
|
modifier = Modifier
|
||||||
colors = sliderColors,
|
.size(14.dp)
|
||||||
thumbSize = DpSize(10.dp, 10.dp),
|
.clip(CircleShape)
|
||||||
|
.background(accent),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
<script lang="ts" generics="T">
|
<script lang="ts" generics="T">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { Loader2 } from 'lucide-svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
items,
|
items,
|
||||||
getKey,
|
getKey,
|
||||||
item
|
item,
|
||||||
|
hasMore = false,
|
||||||
|
onLoadMore
|
||||||
}: {
|
}: {
|
||||||
items: T[];
|
items: T[];
|
||||||
getKey: (it: T) => string;
|
getKey: (it: T) => string;
|
||||||
item: Snippet<[T]>;
|
item: Snippet<[T]>;
|
||||||
|
// When true, clicking a rail letter that's not in the currently
|
||||||
|
// loaded items will trigger onLoadMore() repeatedly until that
|
||||||
|
// letter surfaces (or no more pages exist). Lets a paginated
|
||||||
|
// source like createArtistsQuery still feel like a full jump
|
||||||
|
// rail without eager-loading the whole dataset.
|
||||||
|
hasMore?: boolean;
|
||||||
|
onLoadMore?: () => Promise<unknown> | unknown;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Bucket the first character into one of three classes so the
|
// Bucket the first character into one of three classes so the
|
||||||
@@ -56,10 +67,42 @@
|
|||||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
const railEntries: string[] = ['#', ...ALPHABET, '&'];
|
const railEntries: string[] = ['#', ...ALPHABET, '&'];
|
||||||
|
|
||||||
function jumpTo(bucket: string) {
|
// Bucket the caller is currently chasing via onLoadMore. Used to
|
||||||
|
// show a spinner on the rail button while pages stream in.
|
||||||
|
let pendingBucket = $state<string | null>(null);
|
||||||
|
|
||||||
|
function scrollNow(bucket: string) {
|
||||||
const el = document.getElementById(`alpha-${bucket}`);
|
const el = document.getElementById(`alpha-${bucket}`);
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function jumpTo(bucket: string) {
|
||||||
|
// Already loaded — jump in the same frame.
|
||||||
|
if (populated.has(bucket)) {
|
||||||
|
scrollNow(bucket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No paginated source wired in — nothing to load.
|
||||||
|
if (!hasMore || !onLoadMore) return;
|
||||||
|
// Don't stack loaders.
|
||||||
|
if (pendingBucket !== null) return;
|
||||||
|
|
||||||
|
pendingBucket = bucket;
|
||||||
|
try {
|
||||||
|
// Walk pages until the bucket appears or we exhaust the
|
||||||
|
// dataset. `populated` is $derived from `items`; `tick()`
|
||||||
|
// forces Svelte to flush the prop update before we re-read
|
||||||
|
// populated for the loop condition.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
while (hasMore && !populated.has(bucket)) {
|
||||||
|
await onLoadMore();
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
if (populated.has(bucket)) scrollNow(bucket);
|
||||||
|
} finally {
|
||||||
|
pendingBucket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="alpha-layout">
|
<div class="alpha-layout">
|
||||||
@@ -80,17 +123,28 @@
|
|||||||
vertically centered via position:sticky + top:50%. -->
|
vertically centered via position:sticky + top:50%. -->
|
||||||
<nav class="alpha-rail" aria-label="Jump to letter">
|
<nav class="alpha-rail" aria-label="Jump to letter">
|
||||||
{#each railEntries as entry}
|
{#each railEntries as entry}
|
||||||
{@const enabled = populated.has(entry)}
|
{@const isLoaded = populated.has(entry)}
|
||||||
|
{@const couldLoad = !isLoaded && hasMore}
|
||||||
|
{@const enabled = isLoaded || couldLoad}
|
||||||
|
{@const isPending = pendingBucket === entry}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rail-btn"
|
class="rail-btn"
|
||||||
class:disabled={!enabled}
|
class:disabled={!enabled}
|
||||||
disabled={!enabled}
|
class:pending={isPending}
|
||||||
|
disabled={!enabled || pendingBucket !== null}
|
||||||
onclick={() => enabled && jumpTo(entry)}
|
onclick={() => enabled && jumpTo(entry)}
|
||||||
aria-label={enabled
|
aria-label={enabled
|
||||||
? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
|
? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
|
||||||
: `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} — no entries`}
|
: `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} — no entries`}
|
||||||
>{entry}</button>
|
aria-busy={isPending}
|
||||||
|
>
|
||||||
|
{#if isPending}
|
||||||
|
<Loader2 size={11} strokeWidth={2} class="spin" aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
{entry}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -146,4 +200,14 @@
|
|||||||
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
|
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
|
||||||
cursor: default;
|
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); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,27 +23,43 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({
|
|||||||
type AnyProps = any;
|
type AnyProps = any;
|
||||||
|
|
||||||
describe('AlphabeticalGrid', () => {
|
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, {
|
render(AlphabeticalGrid, {
|
||||||
props: {
|
props: {
|
||||||
items,
|
items,
|
||||||
getKey: (it: Item) => it.key,
|
getKey: (it: Item) => it.key,
|
||||||
item: itemSnippet
|
item: itemSnippet
|
||||||
|
// hasMore default = false → empty buckets stay disabled.
|
||||||
} as AnyProps
|
} as AnyProps
|
||||||
});
|
});
|
||||||
// Populated buckets are enabled with 'Jump to <letter>' label.
|
// Populated buckets are enabled with 'Jump to <letter>' label.
|
||||||
expect(screen.getByRole('button', { name: 'Jump to A' })).not.toBeDisabled();
|
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 B' })).not.toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: 'Jump to D' })).not.toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Jump to D' })).not.toBeDisabled();
|
||||||
// Empty buckets still render but are disabled and labelled
|
// Empty buckets disabled when nothing more could load. aria-label
|
||||||
// 'X — no entries' so screen readers announce the empty state.
|
// changes too so screen readers announce the empty state.
|
||||||
expect(screen.getByRole('button', { name: 'C — no entries' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'C — no entries' })).toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: 'Z — 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: 'Numbers — no entries' })).toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: 'Symbols — 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', () => {
|
test('renders items in given order followed by the jump rail', () => {
|
||||||
const { container } = render(AlphabeticalGrid, {
|
const { container } = render(AlphabeticalGrid, {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -14,11 +14,17 @@
|
|||||||
import { formatDuration } from '$lib/media/duration';
|
import { formatDuration } from '$lib/media/duration';
|
||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||||
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
|
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
|
||||||
|
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
|
|
||||||
const current = $derived(player.current);
|
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 skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
|
||||||
const skipNextDisabled = $derived(
|
const skipNextDisabled = $derived(
|
||||||
player.index === player.queue.length - 1 && player.repeat === 'off'
|
player.index === player.queue.length - 1 && player.repeat === 'off'
|
||||||
@@ -226,10 +232,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom row: full-width seek slider + time labels -->
|
<!-- Bottom row: full-width seek slider + time labels.
|
||||||
|
Reads the smoothed position so the slider thumb glides
|
||||||
|
between server ticks at playback rate. -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-9 shrink-0 text-right text-[10px] tabular-nums text-text-secondary">
|
<span class="w-9 shrink-0 text-right text-[10px] tabular-nums text-text-secondary">
|
||||||
{formatDuration(player.position)}
|
{formatDuration(smoothed.value)}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -237,7 +245,7 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max={player.duration || 0}
|
max={player.duration || 0}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={player.position}
|
value={smoothed.value}
|
||||||
oninput={onSeekInput}
|
oninput={onSeekInput}
|
||||||
class="flex-1 accent-accent"
|
class="flex-1 accent-accent"
|
||||||
/>
|
/>
|
||||||
@@ -346,10 +354,11 @@
|
|||||||
<SkipForward size={20} strokeWidth={1.5} fill="currentColor" />
|
<SkipForward size={20} strokeWidth={1.5} fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Seek row -->
|
<!-- Seek row — reads smoothed position so the thumb glides
|
||||||
|
between server ticks. See useSmoothPosition. -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
|
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
|
||||||
{formatDuration(player.position)}
|
{formatDuration(smoothed.value)}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -357,7 +366,7 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max={player.duration || 0}
|
max={player.duration || 0}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={player.position}
|
value={smoothed.value}
|
||||||
oninput={onSeekInput}
|
oninput={onSeekInput}
|
||||||
class="flex-1 accent-accent"
|
class="flex-1 accent-accent"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -64,7 +64,15 @@
|
|||||||
starting = true;
|
starting = true;
|
||||||
try {
|
try {
|
||||||
const variant = playlist.system_variant;
|
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
|
// #415: server returns the rotation-aware order (unplayed
|
||||||
// this rotation first). Play it as-is — no client shuffle —
|
// this rotation first). Play it as-is — no client shuffle —
|
||||||
// and tag the queue so play_started carries `source` and
|
// and tag the queue so play_started carries `source` and
|
||||||
@@ -78,7 +86,17 @@
|
|||||||
const detail = await getPlaylist(playlist.id);
|
const detail = await getPlaylist(playlist.id);
|
||||||
const refs = toTrackRefs(detail.tracks);
|
const refs = toTrackRefs(detail.tracks);
|
||||||
if (refs.length > 0) {
|
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 {
|
} finally {
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X } from 'lucide-svelte';
|
|
||||||
import { player, closeQueueDrawer } from '$lib/player/store.svelte';
|
import { player, closeQueueDrawer } from '$lib/player/store.svelte';
|
||||||
import QueueTrackRow from './QueueTrackRow.svelte';
|
import QueueList from './QueueList.svelte';
|
||||||
|
|
||||||
let previouslyFocused: HTMLElement | null = null;
|
let previouslyFocused: HTMLElement | null = null;
|
||||||
let closeButton: HTMLButtonElement | undefined = $state();
|
let closeButton: HTMLButtonElement | undefined = $state();
|
||||||
|
|
||||||
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
|
||||||
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
|
||||||
const m = Math.floor(totalSec / 60);
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
return h > 0 ? `${h}h ${m % 60}m` : `${m} min`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (player.queueDrawerOpen) {
|
if (player.queueDrawerOpen) {
|
||||||
previouslyFocused = document.activeElement as HTMLElement | null;
|
previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
@@ -40,35 +32,8 @@
|
|||||||
aria-hidden={!player.queueDrawerOpen}
|
aria-hidden={!player.queueDrawerOpen}
|
||||||
inert={!player.queueDrawerOpen}
|
inert={!player.queueDrawerOpen}
|
||||||
class="fixed top-0 right-0 h-full w-full sm:w-96 bg-surface z-50
|
class="fixed top-0 right-0 h-full w-full sm:w-96 bg-surface z-50
|
||||||
transition-transform duration-200 flex flex-col
|
transition-transform duration-200
|
||||||
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
|
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} />
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold">Queue</h2>
|
|
||||||
<p class="text-xs text-text-secondary">
|
|
||||||
{player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'}
|
|
||||||
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
bind:this={closeButton}
|
|
||||||
aria-label="Close queue"
|
|
||||||
onclick={() => closeQueueDrawer()}
|
|
||||||
class="text-text-secondary hover:text-text-primary"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
|
||||||
{#if player.queue.length === 0}
|
|
||||||
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
|
||||||
{:else}
|
|
||||||
{#each player.queue as track, i (track.id)}
|
|
||||||
<QueueTrackRow {track} index={i} isCurrent={i === player.index} />
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X } from 'lucide-svelte';
|
||||||
|
import { player } from '$lib/player/store.svelte';
|
||||||
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||||
|
|
||||||
|
// onClose: when provided, renders an X button in the header so the
|
||||||
|
// slide-in drawer can dismiss itself. The embedded panel on the
|
||||||
|
// now-playing route (visible at lg+ widths) omits it.
|
||||||
|
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
||||||
|
// keyboard users on open.
|
||||||
|
type Props = {
|
||||||
|
onClose?: () => void;
|
||||||
|
closeButtonRef?: HTMLButtonElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { onClose, closeButtonRef = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
||||||
|
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return h > 0 ? `${h}h ${m % 60}m` : `${m} min`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Queue</h2>
|
||||||
|
<p class="text-xs text-text-secondary">
|
||||||
|
{player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'}
|
||||||
|
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if onClose}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bind:this={closeButtonRef}
|
||||||
|
aria-label="Close queue"
|
||||||
|
onclick={onClose}
|
||||||
|
class="text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#if player.queue.length === 0}
|
||||||
|
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
||||||
|
{:else}
|
||||||
|
{#each player.queue as track, i (track.id)}
|
||||||
|
<QueueTrackRow {track} index={i} isCurrent={i === player.index} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@
|
|||||||
<AlphabeticalGrid
|
<AlphabeticalGrid
|
||||||
items={filtered}
|
items={filtered}
|
||||||
getKey={(a: AlbumRef) => a.sort_title || a.title}
|
getKey={(a: AlbumRef) => a.sort_title || a.title}
|
||||||
|
hasMore={!!query.hasNextPage}
|
||||||
|
onLoadMore={() => query.fetchNextPage()}
|
||||||
>
|
>
|
||||||
{#snippet item(album: AlbumRef)}
|
{#snippet item(album: AlbumRef)}
|
||||||
<AlbumCard {album} />
|
<AlbumCard {album} />
|
||||||
|
|||||||
@@ -71,6 +71,8 @@
|
|||||||
<AlphabeticalGrid
|
<AlphabeticalGrid
|
||||||
items={filtered}
|
items={filtered}
|
||||||
getKey={(a: ArtistRef) => a.sort_name || a.name}
|
getKey={(a: ArtistRef) => a.sort_name || a.name}
|
||||||
|
hasMore={!!query.hasNextPage}
|
||||||
|
onLoadMore={() => query.fetchNextPage()}
|
||||||
>
|
>
|
||||||
{#snippet item(a: ArtistRef)}
|
{#snippet item(a: ArtistRef)}
|
||||||
<ArtistCard artist={a} />
|
<ArtistCard artist={a} />
|
||||||
|
|||||||
@@ -14,11 +14,17 @@
|
|||||||
} from '$lib/player/store.svelte';
|
} from '$lib/player/store.svelte';
|
||||||
import { formatDuration } from '$lib/media/duration';
|
import { formatDuration } from '$lib/media/duration';
|
||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||||
|
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
|
import QueueList from '$lib/components/QueueList.svelte';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
|
|
||||||
const current = $derived(player.current);
|
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 skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
|
||||||
const skipNextDisabled = $derived(
|
const skipNextDisabled = $derived(
|
||||||
player.index === player.queue.length - 1 && player.repeat === 'off'
|
player.index === player.queue.length - 1 && player.repeat === 'off'
|
||||||
@@ -84,7 +90,13 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<main class="flex flex-1 flex-col items-center justify-center gap-6 px-4 pb-6">
|
<!-- At lg+ the player splits into two panes: the focus column on
|
||||||
|
the left and a permanent Queue panel on the right. Below lg
|
||||||
|
the queue stays in the slide-in QueueDrawer (toggle button in
|
||||||
|
the bottom row). Wide-screen viewers get queue context without
|
||||||
|
opening a drawer; mobile keeps the focused single-column flow. -->
|
||||||
|
<main class="flex flex-1 overflow-hidden">
|
||||||
|
<section class="flex flex-1 flex-col items-center justify-center gap-6 px-4 pb-6 overflow-y-auto">
|
||||||
<div class="aspect-square w-full max-w-md overflow-hidden rounded-lg bg-surface-hover shadow-lg">
|
<div class="aspect-square w-full max-w-md overflow-hidden rounded-lg bg-surface-hover shadow-lg">
|
||||||
<img
|
<img
|
||||||
src={coverUrl(current.album_id)}
|
src={coverUrl(current.album_id)}
|
||||||
@@ -123,13 +135,13 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max={player.duration || 0}
|
max={player.duration || 0}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={player.position}
|
value={smoothed.value}
|
||||||
oninput={onSeekInput}
|
oninput={onSeekInput}
|
||||||
aria-label="Seek"
|
aria-label="Seek"
|
||||||
class="w-full accent-accent"
|
class="w-full accent-accent"
|
||||||
/>
|
/>
|
||||||
<div class="mt-1 flex justify-between text-xs text-text-secondary tabular-nums">
|
<div class="mt-1 flex justify-between text-xs text-text-secondary tabular-nums">
|
||||||
<span>{formatDuration(player.position)}</span>
|
<span>{formatDuration(smoothed.value)}</span>
|
||||||
<span>{formatDuration(player.duration)}</span>
|
<span>{formatDuration(player.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,12 +242,20 @@
|
|||||||
aria-label="Open queue"
|
aria-label="Open queue"
|
||||||
class="flex items-center gap-1.5 rounded px-3 py-2 min-h-[44px]
|
class="flex items-center gap-1.5 rounded px-3 py-2 min-h-[44px]
|
||||||
text-text-secondary hover:text-text-primary
|
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"
|
||||||
>
|
>
|
||||||
<ListMusic size={20} strokeWidth={1.5} />
|
<ListMusic size={20} strokeWidth={1.5} />
|
||||||
<span class="text-sm tabular-nums">{player.queue.length}</span>
|
<span class="text-sm tabular-nums">{player.queue.length}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
<aside
|
||||||
|
aria-label="Queue"
|
||||||
|
class="hidden lg:flex w-96 xl:w-[28rem] flex-col border-l border-border bg-surface"
|
||||||
|
>
|
||||||
|
<QueueList />
|
||||||
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user