Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2038028d42 | ||
|
|
723293110d | ||
|
|
41ebf1405b | ||
|
|
f2dcf2596d |
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -86,7 +87,14 @@ private fun QueueList(
|
|||||||
onJumpTo: (Int) -> Unit,
|
onJumpTo: (Int) -> Unit,
|
||||||
onToggleLike: (String) -> Unit,
|
onToggleLike: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
// Open scrolled to the now-playing track so it's in view immediately.
|
||||||
|
// Seeding the initial index (rather than animating post-layout) avoids a
|
||||||
|
// flash of the list top; it's captured once per entry, so the view stays
|
||||||
|
// put as the track later auto-advances — matching "show me where I am now."
|
||||||
|
val listState = rememberLazyListState(
|
||||||
|
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||||
|
)
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||||
QueueRow(
|
QueueRow(
|
||||||
track = track,
|
track = track,
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": [
|
|
||||||
"config:recommended",
|
|
||||||
":semanticCommits"
|
|
||||||
],
|
|
||||||
"baseBranches": ["dev"],
|
|
||||||
"timezone": "America/New_York",
|
|
||||||
"schedule": ["every weekend"],
|
|
||||||
"prHourlyLimit": 2,
|
|
||||||
"prConcurrentLimit": 8,
|
|
||||||
"ignorePaths": [
|
|
||||||
"**/node_modules/**",
|
|
||||||
"**/vendor/**",
|
|
||||||
"flutter_client/**"
|
|
||||||
],
|
|
||||||
"lockFileMaintenance": {
|
|
||||||
"enabled": true,
|
|
||||||
"schedule": ["before 5am on the first day of the month"]
|
|
||||||
},
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
|
|
||||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
|
||||||
"automerge": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Hold all major bumps for manual approval via the dependency dashboard",
|
|
||||||
"matchUpdateTypes": ["major"],
|
|
||||||
"automerge": false,
|
|
||||||
"dependencyDashboardApproval": true,
|
|
||||||
"addLabels": ["deps", "deps:major"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Group Go module updates into one PR",
|
|
||||||
"matchManagers": ["gomod"],
|
|
||||||
"groupName": "go modules"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Group CI workflow action bumps",
|
|
||||||
"matchManagers": ["github-actions"],
|
|
||||||
"groupName": "ci actions"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Group Docker base-image bumps (Dockerfile + compose)",
|
|
||||||
"matchManagers": ["dockerfile", "docker-compose"],
|
|
||||||
"groupName": "docker images"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Group the Android Gradle/Kotlin toolchain",
|
|
||||||
"matchManagers": ["gradle", "gradle-wrapper"],
|
|
||||||
"groupName": "android gradle"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Group web npm non-major bumps",
|
|
||||||
"matchManagers": ["npm"],
|
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
|
||||||
"groupName": "web npm (non-major)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -35,5 +35,9 @@
|
|||||||
transition-transform duration-200
|
transition-transform duration-200
|
||||||
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
|
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
|
||||||
>
|
>
|
||||||
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} />
|
<QueueList
|
||||||
|
onClose={() => closeQueueDrawer()}
|
||||||
|
active={player.queueDrawerOpen}
|
||||||
|
bind:closeButtonRef={closeButton}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { X } from 'lucide-svelte';
|
import { X } from 'lucide-svelte';
|
||||||
import { player } from '$lib/player/store.svelte';
|
import { player } from '$lib/player/store.svelte';
|
||||||
import QueueTrackRow from './QueueTrackRow.svelte';
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||||
@@ -8,12 +9,33 @@
|
|||||||
// now-playing route (visible at lg+ widths) omits it.
|
// now-playing route (visible at lg+ widths) omits it.
|
||||||
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
||||||
// keyboard users on open.
|
// keyboard users on open.
|
||||||
|
// active: true when the queue is on-screen (drawer open, or the always-
|
||||||
|
// visible now-playing panel). Flipping it true scrolls the now-playing
|
||||||
|
// row into view — parity with the Android queue, which opens positioned
|
||||||
|
// on the current track.
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
closeButtonRef?: HTMLButtonElement;
|
closeButtonRef?: HTMLButtonElement;
|
||||||
|
active?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { onClose, closeButtonRef = $bindable() }: Props = $props();
|
let { onClose, closeButtonRef = $bindable(), active = true }: Props = $props();
|
||||||
|
|
||||||
|
let scrollBody: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
// When the queue becomes visible, center the now-playing row in view. The
|
||||||
|
// index/length are read untracked so this fires once per open (matching the
|
||||||
|
// Android queue's open-positioned behavior) rather than following the track
|
||||||
|
// as it auto-advances. Deferred a frame so the drawer's slide-in has settled.
|
||||||
|
$effect(() => {
|
||||||
|
if (!active || !scrollBody) return;
|
||||||
|
const body = scrollBody;
|
||||||
|
const index = untrack(() => player.index);
|
||||||
|
if (untrack(() => player.queue.length) === 0) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
(body.children[index] as HTMLElement | undefined)?.scrollIntoView({ block: 'center' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
||||||
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
||||||
@@ -45,7 +67,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div bind:this={scrollBody} class="flex-1 overflow-y-auto">
|
||||||
{#if player.queue.length === 0}
|
{#if player.queue.length === 0}
|
||||||
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -168,9 +168,14 @@
|
|||||||
style="display: none"
|
style="display: none"
|
||||||
></audio>
|
></audio>
|
||||||
|
|
||||||
<QueueDrawer />
|
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<!-- QueueDrawer must be inside the provider: its rows render LikeButton,
|
||||||
|
which calls useQueryClient() at init. The drawer's <aside> is always
|
||||||
|
mounted, so the moment the queue is populated (on first play) those
|
||||||
|
LikeButtons instantiate — outside the provider they throw
|
||||||
|
"No QueryClient was found" and abort the play flush. -->
|
||||||
|
<QueueDrawer />
|
||||||
|
|
||||||
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
|
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
|
||||||
<Shell>{@render children()}</Shell>
|
<Shell>{@render children()}</Shell>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ if (typeof window !== 'undefined') {
|
|||||||
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
|
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsdom doesn't implement Element.prototype.scrollIntoView. Components that
|
||||||
|
// call it (queue auto-scroll to the now-playing row, the alphabetical rail)
|
||||||
|
// would throw an unhandled TypeError in tests — which fails the run even when
|
||||||
|
// every assertion passes. No-op it; tests never assert on scroll position.
|
||||||
|
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
|
||||||
|
Element.prototype.scrollIntoView = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
// W-T3 moved toast rendering out of per-page markup into a single
|
// W-T3 moved toast rendering out of per-page markup into a single
|
||||||
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages
|
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages
|
||||||
// without the layout, so we mount ToastHost here so `pushToast()` calls
|
// without the layout, so we mount ToastHost here so `pushToast()` calls
|
||||||
|
|||||||
Reference in New Issue
Block a user