Files
SoulStream/src/mobile/Sheet.svelte
T
KeelrLeek 1258b2b78d android: the rows go where you put them — a real drag on the playlist and the queue
The phone stood in for the desktop's drag with Move up / Move down / Move to
the top on a row's sheet, which is fine but is not what a finger is for. Press
and hold a row for 320ms now and it lifts, the rows below make room, and
letting go lands it — on the playlist page and on the player sheet's queue,
through one action in src/mobile/reorder.js. The Move rows stay: they are the
accessible path round 28 named to a screen reader, and they are the only way
to reorder while the filter is on. Release a press without having moved it and
the row's sheet opens exactly as a long press always opened it, which is what
keeps the queue's sheet reachable at all — a queue row has no ⋯, so
`oncontextmenu` is its only door to Play now, Move to next and Remove from
queue.

Most of the work is in not fighting the page. Nothing carries
`touch-action: none`, because a flick that starts on a row is how the list is
scrolled and `touch-action` is latched at touchstart anyway; the scroll is
refused instead by a non-passive `touchmove` listener added at the lift, which
works precisely because the finger has been still for 320ms and the browser
has not started one. A press that moves more than 8px before then is a scroll
and the gesture lets go of it. Click and contextmenu guards sit on the
container in the capture phase while a lift is live, or a released drag would
fire the row's own click and play the track. Rows are measured once at the
lift in the scroller's content space, so the edge pull — within 64px of either
edge — leaves the snapshot true, and each displaced row moves by the exact
difference between its top and its neighbour's rather than by one assumed row
height, because the queue face puts album separators between rows.

No new mutation and no new command: `moveItem` and `moveInQueue` each take a
numeric target now as well as "up" / "down" / "top", so there is still exactly
one call to `playlist_set_order` carrying the whole id order, and the existing
queue guards were already the right guards. The drop is optimistic and snaps
back with the error toast if the backend says no. Two refusals cover the
middle of a gesture — the playlist drops nothing if a refetch has replaced its
rows, and the queue drops nothing if `player.qi` has moved — and dragging is
off entirely while the playlist's filter has anything in it, because a
filtered list's visible order is not the stored order. Both walks drive it
with real pointer events and assert the order out of the store: a drag, a drag
back, a refusal under the filter, a press that still opens the sheet, and a
drop the backend is armed to reject.
2026-09-14 03:24:26 -05:00

301 lines
17 KiB
Svelte

<script>
/* The player sheet. Three FACES — art, lyrics, queue — of one screen
rather than three destinations, because on a phone the thing you are
listening to should never be somewhere you navigate away to.
Two arrivals. Tapping the mini bar is a container transform: the
artwork you tapped IS the big artwork, so it flies there and the sheet
materialises around it (FLIP on the spatial spring). Anything else
slides up. Drag the head or the art down to put it away; swipe them
sideways to skip; drag the lane to seek. */
import { tick } from "svelte";
import Icon from "../components/Icon.svelte";
import Lane from "./Lane.svelte";
import Cover from "./Cover.svelte";
import { art } from "./art.svelte.js";
import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat, moveInQueue } from "./player.svelte.js";
import { nav, closeSheet, morphSource, openHalf, openViewer, push } from "./nav.svelte.js";
import { lyr, loadLyrics } from "./lyrics.svelte.js";
import { mmss, drag } from "./util.js";
import { reorder } from "./reorder.js";
let frameEl = $state(null);
let faceEl = $state(null);
let shown = $state(false);
let morphing = $state(false);
let morphTimer = null;
const cover = $derived(player.album?.art_path ? art[player.album.art_path] : "");
const face = $derived(nav.face);
const small = $derived(face !== "art");
const nextEntry = $derived(player.queue[player.qi + 1] ?? null);
const crossing = $derived(!!nextEntry && nextEntry.album?.title !== player.album?.title);
const played = $derived(player.duration ? Math.min(1, player.position / player.duration) : 0);
const remaining = $derived(Math.max(0, (player.duration ?? 0) - player.position));
const playing = $derived(player.status === "playing" || player.status === "buffering");
const artistName = $derived(player.track?.artist || player.album?.artist || "");
/* the artist page stands on the library and fills in from the catalog,
so any name opens it */
const artistInLibrary = $derived(!!artistName);
/* ── arrival ─────────────────────────────────────────────────────── */
$effect(() => {
if (!nav.sheet) {
shown = false;
morphing = false;
clearTimeout(morphTimer);
return;
}
if (nav.morph && morphSource.rect) {
morphing = true;
shown = false;
tick().then(() => requestAnimationFrame(() => {
shown = true;
const dur = flip(morphSource.rect);
clearTimeout(morphTimer);
/* once the art has landed the sheet is an ordinary sheet
again: drags spring back and a close slides down */
morphTimer = setTimeout(() => { morphing = false; }, dur + 40);
}));
} else {
morphing = false;
shown = true;
}
});
/* Somewhere sensible to land when the sheet opens: its own Close. The
gate is nav.sheet and not "shown" — "shown" is false for a frame at
the start of a morph arrival and the dialog has to be live by then —
and preventScroll because that arrival is measuring rects in the
same frames. */
$effect(() => {
if (!nav.sheet) return;
const t = setTimeout(() => {
const el = document.querySelector(".sheet.up .np-head .iconbtn");
if (el && !el.closest(".sheet")?.contains(document.activeElement)) el.focus({ preventScroll: true });
}, 160);
return () => clearTimeout(t);
});
function flip(a) {
const to = frameEl;
const css = getComputedStyle(document.documentElement);
const dur = parseFloat(css.getPropertyValue("--spd-spatial-default")) || 480;
if (!a || !to || typeof to.animate !== "function") return 0;
const b = to.getBoundingClientRect();
if (!a.width || !b.width) return 0;
const s = a.width / b.width;
const dx = a.left + a.width / 2 - (b.left + b.width / 2);
const dy = a.top + a.height / 2 - (b.top + b.height / 2);
const ease = css.getPropertyValue("--sp-spatial-default").trim() || "ease-out";
to.animate(
[{ transform: `translate(${dx}px,${dy}px) scale(${s})` }, { transform: "none" }],
{ duration: dur, easing: ease, fill: "both" },
);
return dur;
}
const sheetDrag = {
from: (t) => !!(t.closest(".np-head") || t.closest(".np-art")),
move: (dx, dy, el) => { if (dy > 0) el.style.transform = `translateY(${dy}px)`; },
end: (dx, dy, el) => {
el.style.transform = "";
if (dy > 110) closeSheet();
else if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy)) { if (dx < 0) next(); else prev(); }
},
};
function goArtist() {
if (!artistInLibrary) return;
closeSheet();
push("artist", { name: artistName });
}
function goAlbum() {
const a = player.album;
if (!a) return;
if (a.id != null) { closeSheet(); push("album", { id: a.id }); return; }
if (a.catalogId != null) { closeSheet(); push("catalogAlbum", { id: a.catalogId, artist: a.artist, title: a.title }); }
}
function more() {
if (player.track) openHalf("track", { album: player.album, track: player.track });
}
/* ── lyrics: the store (lyrics.svelte.js), asked for the playing track
whenever the face is up ───────────────────────────────────────── */
$effect(() => {
if (face !== "lyrics" || !player.track) return;
void player.track.path;
void loadLyrics();
});
const nowMs = $derived(player.position * 1000);
const activeIdx = $derived.by(() => {
let i = -1;
for (let k = 0; k < lyr.lines.length; k++) {
if (lyr.lines[k].time_ms + lyr.offset <= nowMs) i = k;
else break;
}
return i;
});
/* the active line stays in view */
$effect(() => {
const i = activeIdx;
const host = faceEl;
if (i < 0 || !host || face !== "lyrics") return;
const el = host.querySelector(`[data-ln="${i}"]`);
if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", behavior: "smooth" });
});
function lineSeek(l) { seekSecs(Math.max(0, (l.time_ms + lyr.offset) / 1000)); }
/* ── the queue, dragged ───────────────────────────────────────────
The same gesture the playlist page has (./reorder.js). Press and
release without moving and the row's sheet opens exactly as the long
press always opened it — which matters more here than anywhere: a
queue row has no ⋯, so that sheet is the only door to Play now, Move
to next and Remove from queue. */
let qiAt = -1;
function queueLift() { qiAt = player.qi; }
function queueDrop(from, to) {
/* a track boundary crossed mid-drag moves the whole rendered
window, and these are absolute indices: they would address the
wrong rows. Drop nothing rather than the wrong thing. */
if (player.qi !== qiAt) return;
moveInQueue(qiAt + 1 + from, qiAt + 1 + to);
}
function queueHold(k) {
const i = player.qi + 1 + k;
const e = player.queue[i];
if (e) openHalf("track", { album: e.album, track: e.t, queueIndex: i });
}
const queueDrag = { enabled: true, rowSel: ".row", scrollSel: ".np-face", onLift: queueLift, onDrop: queueDrop, onHold: queueHold };
</script>
<div class="sheet" class:up={shown} class:morphing use:drag={sheetDrag}
role="dialog" aria-modal="true" aria-label={"Now playing" + (player.track ? ": " + player.track.title : "")}
aria-hidden={nav.sheet ? undefined : "true"} inert={!nav.sheet}>
{#if player.album}
<div class="sheet-bg" aria-hidden="true">
<div class="amb on" style:background-image={cover ? `url("${cover}")` : null}></div>
<div class="amb-tint"></div>
<div class="grain"></div>
</div>
<div class="np" class:small>
<div class="np-head">
<button class="iconbtn" onclick={closeSheet} aria-label="Close"><Icon name="chevron-down" size={24} /></button>
<button class="np-ctx" onclick={goAlbum} aria-label="Go to the record">
<div class="k">{player.track?.stream ? "Streaming from " + player.track.stream.username : "Playing from " + (player.from && player.from !== player.album.title ? "playlist" : "album")}</div>
<div class="v">{player.from || player.album.title}</div>
</button>
<button class="iconbtn" onclick={more} aria-label="More"><Icon name="more" size={22} /></button>
</div>
<div class="np-art" class:small onclick={() => openViewer(player.album?.art_path)}
role="button" tabindex="0" onkeydown={() => {}} aria-label={"View album artwork" + (player.album?.title ? " — " + player.album.title : "")}>
<div class="np-frame" bind:this={frameEl}><Cover path={player.album.art_path} eager size={64} /></div>
</div>
{#if !small}
<div class="np-spacer">
{#if nextEntry}
<button class="np-next" onclick={() => playQueueAt(player.qi + 1)}>
<Icon name="skip-forward" size={13} />
<span><b>Next</b> {nextEntry.t.title}{#if crossing}<i> · {nextEntry.album?.artist}</i>{/if}</span>
<Icon name="chevron-right" size={14} />
</button>
{/if}
</div>
{:else}
<div class="np-face" bind:this={faceEl}>
{#if face === "lyrics"}
<div class="lyr" oncontextmenu={(e) => { e.preventDefault(); openHalf("lyrics"); }} role="presentation">
{#if lyr.status === "loading"}
<div class="lyr-note">Looking for the words…</div>
{:else if lyr.lines.length}
{#each lyr.lines as l, i (i)}
{#if !l.text.trim()}
<div class="lyr-gap"></div>
{:else}
<button class="ln" class:past={i < activeIdx} class:now={i === activeIdx} class:words={i === activeIdx && l.words?.length} data-ln={i} onclick={() => lineSeek(l)}>
{#if i === activeIdx && l.words?.length}
{#each l.words as w, k (k)}<span class:fill={w.time_ms + lyr.offset <= nowMs}>{w.text}</span>{k < l.words.length - 1 ? " " : ""}{/each}
{:else}
{l.text}
{/if}
</button>
{/if}
{/each}
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Out of time, or wrong? ⋯</button>
{:else if lyr.plain}
<div class="lyr-plain">{lyr.plain}</div>
<div class="lyr-note">Plain lyrics — nothing timed for this recording.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Wrong lyrics? ⋯</button>
{:else if lyr.status === "instrumental"}
<div class="lyr-note">Instrumental.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Not so? ⋯</button>
{:else if lyr.failed}
<div class="lyr-note">Couldn't look up the words — the lookup didn't answer.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Try again, or paste them ⋯</button>
{:else}
<div class="lyr-note">No lyrics found for this track.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Paste them, or search ⋯</button>
{/if}
</div>
{:else}
<div class="q-face" use:reorder={queueDrag}>
<div class="sec" style="padding-top:8px">
<h2>Up next <em>· from {player.from || player.album.title}</em></h2>
<button class="all" class:on={player.shuffle} onclick={toggleShuffle} aria-label="Shuffle"><Icon name="shuffle" size={14} /></button>
</div>
{#each player.queue.slice(player.qi + 1, player.qi + 41) as e, k (player.qi + 1 + k)}
{@const prevAlbum = k === 0 ? player.album?.title : player.queue[player.qi + k]?.album?.title}
{#if e.album?.title !== prevAlbum}
<div class="q-break">{e.album?.artist} · {e.album?.title}</div>
{/if}
<button class="row" onclick={() => playQueueAt(player.qi + 1 + k)} oncontextmenu={(ev) => { ev.preventDefault(); openHalf("track", { album: e.album, track: e.t, queueIndex: player.qi + 1 + k }); }}>
<span class="art"><Cover path={e.album?.art_path} size={18} /></span>
<span class="b"><span class="t">{e.t.title}</span><span class="m">{e.t.artist || e.album?.artist}</span></span>
<span class="r">{mmss(e.t.duration_secs)}</span>
</button>
{/each}
{#if player.qi + 1 >= player.queue.length}
<div class="stub"><b>End of the queue</b>Nothing after this.</div>
{/if}
</div>
{/if}
</div>
{/if}
<div class="np-meta">
<div class="b">
<div class="np-t">{player.track?.title}</div>
{#if artistInLibrary}
<button class="np-a" onclick={goArtist}>{artistName}</button>
{:else}
<span class="np-a">{artistName}</span>
{/if}
</div>
</div>
<div class="np-scrub">
<Lane height={38} peaks={player.peaks} heldPeaks={player.heldPeaks} phase={player.phase} coverage={player.coverage} played={played} onseek={seekFrac} />
<div class="times"><span>{mmss(player.position)}</span><span>-{mmss(remaining)}</span></div>
</div>
<div class="np-transport">
<button class="iconbtn" class:on={player.shuffle} class:muted={!player.shuffle} onclick={toggleShuffle} aria-label="Shuffle"><Icon name="shuffle" size={21} /></button>
<button class="iconbtn" onclick={prev} aria-label="Previous"><Icon name="skip-back" size={30} /></button>
<button class="disc lg" onclick={toggle} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={27} /></button>
<button class="iconbtn" onclick={next} aria-label="Next"><Icon name="skip-forward" size={30} /></button>
<button class="iconbtn" class:on={player.repeat > 0} class:muted={!player.repeat} onclick={cycleRepeat} aria-label="Repeat"><Icon name={player.repeat === 2 ? "repeat-one" : "repeat"} size={21} /></button>
</div>
<div class="np-foot">
<div class="np-faces" role="tablist" aria-label="Player faces">
{#each [["art", "disc", "Art"], ["lyrics", "lyrics", "Lyrics"], ["queue", "playlist", "Queue"]] as [f, ic, lb] (f)}
<button class="fbtn" class:on={face === f} onclick={() => (nav.face = f)} role="tab" aria-selected={face === f}><Icon name={ic} size={17} /><span>{lb}</span></button>
{/each}
</div>
</div>
</div>
{/if}
</div>