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.
301 lines
17 KiB
Svelte
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>
|