forked from Keelrleek/SoulStream
dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice
This commit is contained in:
1 parent
4efe85b6bf
commit
1dd8573808
41 files changed
+9288
-647
No files matched your search
+14
-2
@@ -4,7 +4,6 @@
|
||||
import PlayerBar from "./components/PlayerBar.svelte";
|
||||
import NowPlaying from "./components/NowPlaying.svelte";
|
||||
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||||
import LyricsOverlay from "./components/LyricsOverlay.svelte";
|
||||
import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte";
|
||||
import ImportWindow from "./components/ImportWindow.svelte";
|
||||
import LoginModal from "./components/LoginModal.svelte";
|
||||
@@ -23,8 +22,10 @@
|
||||
import CatalogArtist from "./views/CatalogArtist.svelte";
|
||||
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
||||
import CatalogDiscography from "./views/CatalogDiscography.svelte";
|
||||
import Lyrics from "./views/Lyrics.svelte";
|
||||
import FollowToast from "./components/FollowToast.svelte";
|
||||
import ImportToast from "./components/ImportToast.svelte";
|
||||
import SetlistScanChip from "./components/SetlistScanChip.svelte";
|
||||
import {
|
||||
ui,
|
||||
initEvents,
|
||||
@@ -48,6 +49,7 @@
|
||||
catalogArtist: CatalogArtist,
|
||||
catalogAlbum: CatalogAlbum,
|
||||
catalogDiscography: CatalogDiscography,
|
||||
lyrics: Lyrics,
|
||||
};
|
||||
const CurrentView = $derived(views[ui.view] ?? Home);
|
||||
|
||||
@@ -82,6 +84,16 @@
|
||||
// --bar-h + air rather than --pane-pad. Its ambience wash is the one
|
||||
// exception and deliberately runs to the pane top behind the glass.
|
||||
"home",
|
||||
// The playlist editor follows the album-page rule: its hero banner
|
||||
// runs under the frost and the page takes padding-top: --pane-pad.
|
||||
"playlist",
|
||||
// The playlist shelf follows the Library exception: its blurred
|
||||
// ambience pins behind the glass and must begin at the very top of
|
||||
// the pane, so the page carries its own top inset instead.
|
||||
"playlists",
|
||||
// The lyrics room follows the Library exception too: its full-bleed
|
||||
// duotone backdrop pins behind the glass from the very pane top.
|
||||
"lyrics",
|
||||
]);
|
||||
|
||||
initEvents();
|
||||
@@ -127,8 +139,8 @@
|
||||
<PlayerBar />
|
||||
<FollowToast />
|
||||
<ImportToast />
|
||||
<SetlistScanChip />
|
||||
<PreviewOverlay />
|
||||
<LyricsOverlay />
|
||||
<LyricsSyncEditor />
|
||||
<ArtworkViewer />
|
||||
<ImportWindow />
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<script>
|
||||
// Fullscreen lyrics: the whole window washed in the playing record's
|
||||
// accent, the same LyricsPane rendered at reading-room scale. Opened from
|
||||
// the pane's expand button; Escape or ✕ returns to the app.
|
||||
import { ui, player, theme } from "../lib/state.svelte.js";
|
||||
import LyricsPane from "./LyricsPane.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
const artist = $derived.by(() => {
|
||||
const track = player.track;
|
||||
if (!track) return "";
|
||||
return (track.artist ?? "").trim() || String(track.sub ?? "").split(" — ")[0].trim();
|
||||
});
|
||||
|
||||
function onkeydown(e) {
|
||||
if (e.key === "Escape") ui.lyricsFull = false;
|
||||
}
|
||||
|
||||
// Close with the track when playback stops entirely — a lyrics room with
|
||||
// nothing playing is a dead end.
|
||||
$effect(() => {
|
||||
if (ui.lyricsFull && !player.track) ui.lyricsFull = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={ui.lyricsFull ? onkeydown : undefined} />
|
||||
|
||||
{#if ui.lyricsFull}
|
||||
<div class="room" style="--wash: {theme.accent}">
|
||||
<header>
|
||||
<div class="who">
|
||||
<p class="t">{player.track?.title ?? ""}</p>
|
||||
{#if artist}<p class="a">{artist}</p>{/if}
|
||||
</div>
|
||||
<button class="close" title="Close (Esc)" onclick={() => (ui.lyricsFull = false)}>
|
||||
<Icon name="shrink" size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<LyricsPane big />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.room {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(120% 90% at 20% 0%, color-mix(in srgb, var(--wash) 32%, transparent), transparent 60%),
|
||||
radial-gradient(120% 90% at 90% 100%, color-mix(in srgb, var(--wash) 14%, transparent), transparent 55%),
|
||||
var(--bg-base);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 26px 38px 10px;
|
||||
flex: none;
|
||||
}
|
||||
.who {
|
||||
min-width: 0;
|
||||
}
|
||||
.t {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.a {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
flex: none;
|
||||
}
|
||||
.close:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 38px 30px;
|
||||
}
|
||||
.body :global(.lyr) {
|
||||
width: min(820px, 100%);
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -4,14 +4,15 @@
|
||||
// picker and a paste door for tracks nothing automatic can cover. Also
|
||||
// rendered by the fullscreen overlay in `big` mode — one component, two
|
||||
// type scales.
|
||||
import { player, ui, playLocalFile } from "../lib/state.svelte.js";
|
||||
import { player, ui, playLocalFile, goToView } from "../lib/state.svelte.js";
|
||||
import {
|
||||
lyricsStore,
|
||||
lyricIdentity,
|
||||
ensureLyrics,
|
||||
noteTrackChange,
|
||||
activeLineIndex,
|
||||
activeWordIndex,
|
||||
wordClockMs,
|
||||
wordfill,
|
||||
totalOffsetMs,
|
||||
nudgeOffset,
|
||||
setAutoSync,
|
||||
@@ -253,19 +254,26 @@
|
||||
onclick={() => seekTo(line)}
|
||||
title="Play from here"
|
||||
>
|
||||
{#if index === active && line.words?.length}
|
||||
<!-- Real word timing from the source: crisp per-word.
|
||||
This is the ONLY karaoke mode. A line-level source
|
||||
gets the whole-line highlight and nothing more —
|
||||
a sweep interpolated across the line's window
|
||||
tracked the layout, not the voice (it crawled
|
||||
through instrumental gaps), so it read as broken.
|
||||
Word times are carried, never invented (the
|
||||
Noctis rule). -->
|
||||
{@const sungTo = activeWordIndex(line, player.position ?? 0)}
|
||||
<span class="wline">
|
||||
{#if index === active && lyricsStore.wordFill && line.words?.length}
|
||||
<!-- Karaoke word fill over REAL word times (the
|
||||
wordfill mock's dialed variant A): each word
|
||||
wipes across its OWN sung span, silence holds —
|
||||
carried, never invented (the Noctis rule). A
|
||||
line-level source still gets the whole-line
|
||||
highlight and nothing more: the interpolated
|
||||
sweep that tracked the layout instead of the
|
||||
voice stays rejected. -->
|
||||
<span
|
||||
class="wline"
|
||||
use:wordfill={{
|
||||
line,
|
||||
ms: wordClockMs(line, player.position ?? 0),
|
||||
endBound: lyricsStore.lines[index + 1]?.time_ms
|
||||
?? line.words[line.words.length - 1].time_ms + 2500,
|
||||
}}
|
||||
>
|
||||
{#each line.words as word, wi (wi)}
|
||||
<span class="w" class:sung={wi <= sungTo}>{word.text}</span>
|
||||
<span class="fw">{word.text}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else}
|
||||
@@ -331,7 +339,14 @@
|
||||
{#snippet tools()}
|
||||
<span class="tools">
|
||||
{#if !big}
|
||||
<button class="tool" title="Fullscreen lyrics" onclick={() => (ui.lyricsFull = true)}>
|
||||
<button
|
||||
class="tool"
|
||||
title="Open the lyrics page"
|
||||
onclick={() =>
|
||||
goToView("lyrics", {
|
||||
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
|
||||
})}
|
||||
>
|
||||
<Icon name="expand" size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -615,20 +630,29 @@
|
||||
}
|
||||
|
||||
/* ── karaoke rendering inside the active line ──
|
||||
Word mode: crisp steps on the source's own word times. */
|
||||
Word fill (the wordfill mock's dialed variant A): each word is its own
|
||||
gradient-clip raster wiping across the word's real sung span — accent
|
||||
left of the feathered boundary (±8%, mock-dialed), the pane's quieter
|
||||
unsung tone right of it. --lyr-p per element (inherits: false;
|
||||
registered @property in Lyrics.svelte). */
|
||||
.wline {
|
||||
display: block;
|
||||
}
|
||||
.w {
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.12s ease;
|
||||
.fw {
|
||||
--lyr-p: -8%;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--accent-hover) calc(var(--lyr-p) - 8%),
|
||||
var(--text-secondary) calc(var(--lyr-p) + 8%)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
.w + .w {
|
||||
.fw + .fw {
|
||||
margin-left: 0.32em;
|
||||
}
|
||||
.w.sung {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.autochip {
|
||||
flex: none;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
openCurrentArtist,
|
||||
openCurrentAlbum,
|
||||
openCurrentSong,
|
||||
seekPlayer,
|
||||
libraryStore,
|
||||
artBroken,
|
||||
markArtBroken,
|
||||
@@ -943,7 +944,7 @@
|
||||
const now = performance.now();
|
||||
if (now - lastWaveSeekAt >= 75) {
|
||||
lastWaveSeekAt = now;
|
||||
api.playerControl("seek", target);
|
||||
seekPlayer(target);
|
||||
}
|
||||
}
|
||||
function waveUp(e) {
|
||||
@@ -951,7 +952,7 @@
|
||||
waveDragging = false;
|
||||
player.scrubbing = false;
|
||||
const target = waveTarget(e);
|
||||
if (target != null) api.playerControl("seek", target);
|
||||
if (target != null) seekPlayer(target);
|
||||
try {
|
||||
waveEl.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
@@ -1102,10 +1103,13 @@
|
||||
album: currentItem.album ?? metaParts[1] ?? null,
|
||||
duration_secs: currentItem.duration ?? null,
|
||||
// A stream item's path names a file on the PEER's
|
||||
// machine — the row goes in as a placeholder that
|
||||
// streams on click, not as a fake local file.
|
||||
// machine — the row goes in as a stream row that
|
||||
// plays through the resolver, not a fake local
|
||||
// file. Its catalog id and cover ride along.
|
||||
path: currentItem.stream ? null : currentItem.path,
|
||||
art_path: currentItem.stream ? null : (currentItem.art ?? null),
|
||||
trackId: currentItem.trackId ?? null,
|
||||
artUrl: currentItem.stream ? (currentItem.artUrl ?? null) : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
togglePlayerExpanded,
|
||||
showNowPlaying,
|
||||
saveStream,
|
||||
goToView,
|
||||
engineTime,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import {
|
||||
@@ -265,7 +267,11 @@
|
||||
}
|
||||
function openLyrics() {
|
||||
menuOpen = false;
|
||||
ui.lyricsFull = true; // the fullscreen overlay (LyricsOverlay.svelte)
|
||||
// The lyrics PAGE (views/Lyrics.svelte) — the overlay it used to open
|
||||
// is gone.
|
||||
goToView("lyrics", {
|
||||
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Save to library, from the ⋯ menu ---------------------------------
|
||||
@@ -678,7 +684,9 @@
|
||||
const now = performance.now();
|
||||
if (force || now - lastScrubSeekAt >= 75) {
|
||||
lastScrubSeekAt = now;
|
||||
api.playerControl("seek", target);
|
||||
// TRACK time in, ENGINE time out — a set-recording slice's
|
||||
// engine clock is offset by the slice start.
|
||||
api.playerControl("seek", engineTime(target));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,7 +714,7 @@
|
||||
const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6));
|
||||
if (activatingScratch || now - lastScrubSeekAt >= 50) {
|
||||
lastScrubSeekAt = now;
|
||||
api.playerControl("scratch", target, e.clientX < scrubLastX ? -rate : rate);
|
||||
api.playerControl("scratch", engineTime(target), e.clientX < scrubLastX ? -rate : rate);
|
||||
}
|
||||
} else if (!canScratch) {
|
||||
regularScrub(target);
|
||||
@@ -719,7 +727,7 @@
|
||||
if (!draggingSeek) return;
|
||||
const target = previewScrub(e, el);
|
||||
if (target != null) {
|
||||
if (scratchActive) api.playerControl("end-scratch", target);
|
||||
if (scratchActive) api.playerControl("end-scratch", engineTime(target));
|
||||
else regularScrub(target, true); // a simple click remains exact seek
|
||||
}
|
||||
draggingSeek = false;
|
||||
|
||||
@@ -3,23 +3,55 @@
|
||||
// commit. The preview names what matched and what did not; unresolved
|
||||
// rows stay visible placeholders unless the user explicitly leaves them
|
||||
// out. Nothing touches the database until "Create playlist".
|
||||
//
|
||||
// Identified-but-unowned rows are VERIFIED against the CATALOG while
|
||||
// the review is open (Kyle: match them to "the streaming library part,
|
||||
// not Soulseek") — a strict name+duration match against Deezer-backed
|
||||
// catalog search. A hit attaches the catalog track id + artwork to the
|
||||
// row, so the committed playlist plays it as a catalog track.
|
||||
import * as api from "../lib/api.js";
|
||||
import {
|
||||
setlistScan,
|
||||
startYoutubeListen,
|
||||
startFileListen,
|
||||
} from "../lib/setlistScan.svelte.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
mode = "file", // "file": a probed draft arrives via `draft`; "paste": textarea first
|
||||
// "file": a probed draft arrives via `draft`; "paste": textarea
|
||||
// first; "youtube": a link field first — the set's own tracklist
|
||||
// evidence (chapters/description/comments) becomes the draft.
|
||||
mode = "file",
|
||||
draft = null,
|
||||
onimported = () => {},
|
||||
} = $props();
|
||||
|
||||
let pasteText = $state("");
|
||||
let youtubeUrl = $state("");
|
||||
let localDraft = $state(null);
|
||||
// The local listen door: the picked set recording + its optional
|
||||
// pasted tracklist (B-flow: pick first, paste after).
|
||||
let pickedFile = $state(null); // {path, name, duration_secs}
|
||||
let fileTracklist = $state("");
|
||||
let picking = $state(false);
|
||||
let name = $state("");
|
||||
let includeUnresolved = $state(true);
|
||||
let committing = $state(false);
|
||||
let parsing = $state(false);
|
||||
let error = $state("");
|
||||
// The listener: yt-dlp tool state + live scan progress.
|
||||
let tool = $state(null);
|
||||
let toolBusy = $state(false);
|
||||
let denoBusy = $state(false);
|
||||
// "deno" (managed or PATH) | "node"/"bun" (EJS may reject old ones —
|
||||
// deno stays on offer) | null (no runtime at all).
|
||||
const runtimeKind = $derived.by(() => {
|
||||
const r = tool?.runtime;
|
||||
if (!r) return null;
|
||||
return r.startsWith("deno") ? "deno" : r;
|
||||
});
|
||||
let scanning = $state(null); // {stage, pct?, window?, total?, hit?}
|
||||
|
||||
// The working draft: probed (file mode) or built from the paste box.
|
||||
const working = $derived(localDraft ?? draft);
|
||||
@@ -31,8 +63,108 @@
|
||||
committing = false;
|
||||
localDraft = null;
|
||||
pasteText = "";
|
||||
youtubeUrl = "";
|
||||
pickedFile = null;
|
||||
fileTracklist = "";
|
||||
includeUnresolved = true;
|
||||
scanning = null;
|
||||
name = draft?.name ?? "";
|
||||
if (mode === "youtube") {
|
||||
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
|
||||
}
|
||||
if (draft?.entries?.length) startChecks(draft);
|
||||
});
|
||||
$effect(() => {
|
||||
return api.onEvent("playlist:scan-progress", (p) => {
|
||||
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
|
||||
scanning = { ...scanning, ...p };
|
||||
});
|
||||
});
|
||||
|
||||
async function downloadTool() {
|
||||
if (toolBusy) return;
|
||||
toolBusy = true;
|
||||
error = "";
|
||||
scanning = { stage: "tool" };
|
||||
try {
|
||||
await api.ytdlpDownload();
|
||||
tool = await api.ytdlpStatus();
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
toolBusy = false;
|
||||
scanning = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadDeno() {
|
||||
if (denoBusy) return;
|
||||
denoBusy = true;
|
||||
error = "";
|
||||
scanning = { stage: "runtime" };
|
||||
try {
|
||||
await api.denoDownload();
|
||||
tool = await api.ytdlpStatus();
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
denoBusy = false;
|
||||
scanning = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listens run in the BACKGROUND: starting one closes this modal and
|
||||
// hands the whole job to the setlistScan store — its chip narrates the
|
||||
// progress, and the finished draft re-opens the review from there.
|
||||
function listenYoutube() {
|
||||
if (!youtubeUrl.trim()) return;
|
||||
if (!startYoutubeListen(youtubeUrl.trim())) return;
|
||||
close();
|
||||
}
|
||||
|
||||
// The local door picks FIRST: the panel that follows shows the chosen
|
||||
// file and offers an optional pasted tracklist — the same declared
|
||||
// evidence the YouTube door fetches for itself — before the listen
|
||||
// starts. An empty box listens purely by ear.
|
||||
async function pickLocalFile() {
|
||||
if (picking || setlistScan.active) return;
|
||||
picking = true;
|
||||
error = "";
|
||||
try {
|
||||
const f = await api.playlistPickSetFile();
|
||||
if (f) {
|
||||
pickedFile = f;
|
||||
fileTracklist = "";
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
function listenPicked() {
|
||||
if (!pickedFile) return;
|
||||
if (!startFileListen(pickedFile.path, fileTracklist.trim() || null, pickedFile.name))
|
||||
return;
|
||||
close();
|
||||
}
|
||||
|
||||
function cancelScan() {
|
||||
void api.playlistScanCancel();
|
||||
}
|
||||
|
||||
const scanLabel = $derived.by(() => {
|
||||
if (!scanning) return "";
|
||||
if (scanning.stage === "tool") return "Downloading yt-dlp…";
|
||||
if (scanning.stage === "runtime")
|
||||
return `Downloading deno…${scanning.total ? ` ${Math.round(((scanning.received ?? 0) / scanning.total) * 100)}%` : ""}`;
|
||||
if (scanning.stage === "audio")
|
||||
return `Fetching the set's audio…${scanning.pct != null ? ` ${Math.round(scanning.pct)}%` : ""}`;
|
||||
if (scanning.stage === "decode") return "Decoding the set…";
|
||||
if (scanning.stage === "listen")
|
||||
return `Listening… window ${scanning.window ?? 0} of ${scanning.total ?? "?"}`;
|
||||
return "Working…";
|
||||
});
|
||||
async function previewPaste() {
|
||||
if (!pasteText.trim() || parsing) return;
|
||||
@@ -45,6 +177,26 @@
|
||||
} else {
|
||||
localDraft = parsed;
|
||||
if (!name) name = parsed.name ?? "";
|
||||
startChecks(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
parsing = false;
|
||||
}
|
||||
}
|
||||
async function previewYoutube() {
|
||||
if (!youtubeUrl.trim() || parsing) return;
|
||||
parsing = true;
|
||||
error = "";
|
||||
try {
|
||||
const parsed = await api.playlistImportYoutube(youtubeUrl.trim());
|
||||
if (!parsed.entries.length) {
|
||||
error = "The set's tracklist parsed to nothing usable.";
|
||||
} else {
|
||||
localDraft = parsed;
|
||||
name = parsed.name ?? name;
|
||||
startChecks(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
@@ -55,11 +207,109 @@
|
||||
|
||||
const kept = $derived.by(() => {
|
||||
if (!working) return [];
|
||||
// Streams are first-class (identified, playable) — the checkbox
|
||||
// only gates the TRUE unknowns.
|
||||
return includeUnresolved
|
||||
? working.entries
|
||||
: working.entries.filter((e) => e.resolution === "local");
|
||||
: working.entries.filter((e) => e.resolution !== "unresolved");
|
||||
});
|
||||
|
||||
// ---- catalog verification of stream rows, live in the review -------
|
||||
let checks = $state({}); // ordinal → "checking" | {album, cover} | "none"
|
||||
let checkGen = 0;
|
||||
const norm = (s) =>
|
||||
(s ?? "")
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim();
|
||||
/// Strict match: at least one exact side (title or artist), both at
|
||||
/// least partial; duration within ±7 s strengthens; Deezer rank breaks
|
||||
/// ties so a famous track beats a namesake.
|
||||
function pickCatalogMatch(tracks, e) {
|
||||
const wantT = norm(e.title);
|
||||
const wantA = norm(e.artist);
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
for (const t of tracks ?? []) {
|
||||
const tT = norm(t.title);
|
||||
const tA = norm(t.artist);
|
||||
let score = 0;
|
||||
if (tT === wantT) score += 4;
|
||||
else if (tT.includes(wantT) || wantT.includes(tT)) score += 2;
|
||||
if (tA === wantA) score += 4;
|
||||
else if (tA.includes(wantA) || wantA.includes(tA)) score += 2;
|
||||
if (score < 6) continue;
|
||||
if (
|
||||
e.duration_secs &&
|
||||
t.duration_secs &&
|
||||
Math.abs(t.duration_secs - e.duration_secs) <= 7
|
||||
)
|
||||
score += 2;
|
||||
score += Math.min((t.rank ?? 0) / 1_000_000, 1);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
/// Kick the walk OUTSIDE any effect's tracking zone: verifyStreams
|
||||
/// reads and writes `checks`, and doing that inside an $effect made
|
||||
/// every row verdict re-trigger the effect, which bumped the
|
||||
/// generation and killed its own walk — row one froze at "matching…"
|
||||
/// forever. Explicit kicks from the draft-arrival sites, via a
|
||||
/// microtask, keep reactivity out of the loop.
|
||||
function startChecks(w) {
|
||||
checks = {};
|
||||
const gen = ++checkGen;
|
||||
queueMicrotask(() => void verifyStreams(w, gen));
|
||||
}
|
||||
async function verifyStreams(w, gen) {
|
||||
for (const e of w.entries) {
|
||||
if (e.resolution !== "stream") continue;
|
||||
if (gen !== checkGen || !open) return;
|
||||
checks[e.ordinal] = "checking";
|
||||
try {
|
||||
// Anchor-exact first: a row the listener heard carries the
|
||||
// matched release's ISRC, and one /track/isrc: lookup beats
|
||||
// any name matcher. A miss falls through to the name walk.
|
||||
let hit = null;
|
||||
let anchored = false;
|
||||
if (e.isrc) {
|
||||
hit = await api.catalogTrackByIsrc(e.isrc);
|
||||
if (gen !== checkGen) return;
|
||||
anchored = !!hit;
|
||||
}
|
||||
if (!hit) {
|
||||
const res = await api.catalogSearch(`${e.artist} ${e.title}`);
|
||||
if (gen !== checkGen) return;
|
||||
hit = pickCatalogMatch(res?.tracks, e);
|
||||
}
|
||||
if (hit) {
|
||||
// Attach catalog identity to the row commit will send.
|
||||
e.track_id = Number(hit.id);
|
||||
e.art_url = hit.cover ?? null;
|
||||
if (!e.album) e.album = hit.album ?? null;
|
||||
if (!e.duration_secs) e.duration_secs = hit.duration_secs ?? null;
|
||||
checks[e.ordinal] = {
|
||||
album: hit.album ?? "",
|
||||
cover: hit.cover ?? null,
|
||||
anchored,
|
||||
};
|
||||
} else {
|
||||
checks[e.ordinal] = "none";
|
||||
}
|
||||
} catch {
|
||||
if (gen === checkGen) checks[e.ordinal] = "none";
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
}
|
||||
const checkedFound = $derived(
|
||||
Object.values(checks).filter((v) => v && v !== "none" && v !== "checking").length,
|
||||
);
|
||||
|
||||
async function commit() {
|
||||
if (!working || committing) return;
|
||||
const finalName = (name || working.name || "Imported playlist").trim();
|
||||
@@ -71,6 +321,7 @@
|
||||
working.description ?? null,
|
||||
working.kind,
|
||||
kept,
|
||||
working.cover_url ?? null,
|
||||
);
|
||||
onimported(id, finalName);
|
||||
} catch (e) {
|
||||
@@ -80,6 +331,7 @@
|
||||
}
|
||||
|
||||
function close() {
|
||||
checkGen += 1; // stop any verification walk
|
||||
open = false;
|
||||
}
|
||||
|
||||
@@ -111,6 +363,8 @@
|
||||
soulstream: "SoulStream backup",
|
||||
text: "Pasted text",
|
||||
folder: "Folder",
|
||||
youtube: "YouTube",
|
||||
listened: "Recognized by ear",
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -132,6 +386,10 @@
|
||||
<h2>
|
||||
{#if working}
|
||||
Review import
|
||||
{:else if pickedFile}
|
||||
Listen to a set recording
|
||||
{:else if mode === "youtube"}
|
||||
Import a DJ set from YouTube
|
||||
{:else}
|
||||
Paste a tracklist
|
||||
{/if}
|
||||
@@ -142,35 +400,177 @@
|
||||
</header>
|
||||
|
||||
{#if !working}
|
||||
<p class="lead">
|
||||
One track per line. Timestamps make it a setlist —
|
||||
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
|
||||
</p>
|
||||
<textarea
|
||||
bind:value={pasteText}
|
||||
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<button class="btn ghost" onclick={close}>Cancel</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
onclick={previewPaste}
|
||||
disabled={!pasteText.trim() || parsing}
|
||||
>
|
||||
{parsing ? "Reading…" : "Preview"}
|
||||
</button>
|
||||
</footer>
|
||||
{#if pickedFile}
|
||||
<div class="srcfile">
|
||||
<Icon name="music" size={14} />
|
||||
<span class="fname" title={pickedFile.path}>{pickedFile.name}</span>
|
||||
{#if pickedFile.duration_secs != null}
|
||||
<span class="flen">{fmtTime(pickedFile.duration_secs * 1000)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="lead">
|
||||
Listening identifies the tracks by sound. If the set came with a
|
||||
tracklist, paste it too — it names the exact <b>version</b> played
|
||||
where the ear can only name the release it matched, and fills
|
||||
stretches nothing recognized. Leave it empty to listen purely by ear.
|
||||
</p>
|
||||
<textarea
|
||||
class="tl"
|
||||
bind:value={fileTracklist}
|
||||
placeholder={"00:00 Worakls - Red Dressed (Ben Böhmer Remix)\n05:30 Overmono - So U Kno\n…"}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="hint">
|
||||
Optional. Only timestamped lines count — the stamp anchors each
|
||||
name to its place in the mix.
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<button class="btn ghost" onclick={() => (pickedFile = null)}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
onclick={listenPicked}
|
||||
disabled={setlistScan.active}
|
||||
title={setlistScan.active
|
||||
? "A listen is already running — see the chip"
|
||||
: "Identify the tracks by sound — runs in the background"}
|
||||
>
|
||||
Listen
|
||||
</button>
|
||||
</footer>
|
||||
{:else if mode === "youtube"}
|
||||
{#if scanning}
|
||||
<div class="scanbox">
|
||||
<span class="ring"></span>
|
||||
<div class="scanline">{scanLabel}</div>
|
||||
{#if scanning.hit}
|
||||
<div class="scanhit">{scanning.hit}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<footer>
|
||||
<button class="btn ghost" onclick={cancelScan}>Cancel</button>
|
||||
</footer>
|
||||
{:else}
|
||||
<p class="lead">
|
||||
Paste a set's link. <b>Fetch tracklist</b> reads the video's own
|
||||
chapters, description, or a commented tracklist — free and instant.
|
||||
<b>Listen</b> downloads the audio and identifies the tracks by
|
||||
sound: a few minutes, and unreleased material stays an honest ID.
|
||||
</p>
|
||||
<input
|
||||
class="url"
|
||||
type="text"
|
||||
bind:value={youtubeUrl}
|
||||
placeholder="https://www.youtube.com/watch?v=…"
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => e.key === "Enter" && previewYoutube()}
|
||||
/>
|
||||
<div class="toolline">
|
||||
{#if tool?.present}
|
||||
<span class="toolok">
|
||||
yt-dlp {tool.version ?? ""}{tool.managed ? "" : " (from PATH)"}
|
||||
</span>
|
||||
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
|
||||
update
|
||||
</button>
|
||||
{:else}
|
||||
<span>Listening needs the downloader tool:</span>
|
||||
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
|
||||
{toolBusy ? "downloading…" : "download yt-dlp (~17 MB, official nightly)"}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="toolsep">·</span>
|
||||
{#if runtimeKind === "deno"}
|
||||
<span class="toolok">deno</span>
|
||||
{:else if runtimeKind}
|
||||
<span>runtime: {runtimeKind}</span>
|
||||
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
|
||||
{denoBusy ? "downloading…" : "get deno (recommended, ~40 MB)"}
|
||||
</button>
|
||||
{:else}
|
||||
<span>and a JS runtime:</span>
|
||||
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
|
||||
{denoBusy ? "downloading…" : "download deno (~40 MB, official release)"}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="toolsep">·</span>
|
||||
<button
|
||||
class="linkbtn"
|
||||
onclick={pickLocalFile}
|
||||
disabled={parsing || picking || setlistScan.active}
|
||||
title={setlistScan.active
|
||||
? "A listen is already running — see the chip"
|
||||
: ""}
|
||||
>
|
||||
or listen to a local file…
|
||||
</button>
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<button class="btn ghost" onclick={close}>Cancel</button>
|
||||
<button
|
||||
class="btn ghost"
|
||||
onclick={listenYoutube}
|
||||
disabled={!youtubeUrl.trim() ||
|
||||
parsing ||
|
||||
!tool?.present ||
|
||||
!tool?.runtime ||
|
||||
setlistScan.active}
|
||||
title={setlistScan.active
|
||||
? "A listen is already running — see the chip"
|
||||
: !tool?.present
|
||||
? "Download yt-dlp first"
|
||||
: !tool?.runtime
|
||||
? "Download a JS runtime first"
|
||||
: "Identify the tracks by sound — runs in the background"}
|
||||
>
|
||||
Listen
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
onclick={previewYoutube}
|
||||
disabled={!youtubeUrl.trim() || parsing}
|
||||
>
|
||||
{parsing ? "Fetching…" : "Fetch tracklist"}
|
||||
</button>
|
||||
</footer>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="lead">
|
||||
One track per line. Timestamps make it a setlist —
|
||||
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
|
||||
</p>
|
||||
<textarea
|
||||
bind:value={pasteText}
|
||||
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<button class="btn ghost" onclick={close}>Cancel</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
onclick={previewPaste}
|
||||
disabled={!pasteText.trim() || parsing}
|
||||
>
|
||||
{parsing ? "Reading…" : "Preview"}
|
||||
</button>
|
||||
</footer>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="srcline">
|
||||
<span class="chip">{FORMAT_LABEL[working.format] ?? working.format}</span>
|
||||
{#if working.kind === "setlist"}<span class="chip set">Setlist</span>{/if}
|
||||
<span class="srcname" title={working.source_name}>{working.source_name}</span>
|
||||
<span class="counts">
|
||||
<b class="ok">{working.resolved_count}</b> matched
|
||||
<b class="ok">{working.resolved_count}</b> in library
|
||||
{#if working.stream_count > 0}
|
||||
 · <b class="st">{checkedFound}</b>/{working.stream_count} in catalog
|
||||
{/if}
|
||||
{#if working.unresolved_count > 0}
|
||||
 · <b class="un">{working.unresolved_count}</b> unresolved
|
||||
 · <b class="un">{working.unresolved_count}</b> unknown
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -187,8 +587,8 @@
|
||||
{#each working.entries as e (e.ordinal)}
|
||||
<div
|
||||
class="row"
|
||||
class:unresolved={e.resolution !== "local"}
|
||||
class:excluded={!includeUnresolved && e.resolution !== "local"}
|
||||
class:unresolved={e.resolution === "unresolved"}
|
||||
class:excluded={!includeUnresolved && e.resolution === "unresolved"}
|
||||
>
|
||||
<span class="n">{e.ordinal + 1}</span>
|
||||
{#if working.kind === "setlist"}
|
||||
@@ -215,8 +615,41 @@
|
||||
? "relinked"
|
||||
: "tags"}
|
||||
</span>
|
||||
{:else if e.resolution === "stream"}
|
||||
{#if checks[e.ordinal] === "checking"}
|
||||
<span class="tag st dim">matching…</span>
|
||||
{:else if checks[e.ordinal] && checks[e.ordinal] !== "none"}
|
||||
<span
|
||||
class="tag st"
|
||||
title={`${checks[e.ordinal].anchored ? "Exact recording match (ISRC)" : "Matched in the catalog"}${checks[e.ordinal].album ? ` — ${checks[e.ordinal].album}` : ""} · streams like any catalog track`}
|
||||
>
|
||||
<Icon name="check" size={11} />
|
||||
catalog
|
||||
</span>
|
||||
{:else if checks[e.ordinal] === "none"}
|
||||
<span
|
||||
class="tag un"
|
||||
title="No confident catalog match — still plays as a by-name stream"
|
||||
>
|
||||
not in catalog
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="tag st dim"
|
||||
title="Identified but not in your library — plays as a stream"
|
||||
>
|
||||
stream
|
||||
</span>
|
||||
{/if}
|
||||
{:else if e.resolution === "segment"}
|
||||
<span
|
||||
class="tag st dim"
|
||||
title="Unidentified — will play this stretch of the set's own recording"
|
||||
>
|
||||
set audio
|
||||
</span>
|
||||
{:else}
|
||||
<span class="tag un" title="No local match — kept as a placeholder">
|
||||
<span class="tag un" title="Identity too thin to play — kept as a placeholder">
|
||||
unresolved
|
||||
</span>
|
||||
{/if}
|
||||
@@ -242,7 +675,7 @@
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
{#if mode === "paste"}
|
||||
{#if mode === "paste" || mode === "youtube"}
|
||||
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
|
||||
{:else}
|
||||
<button class="btn ghost" onclick={close}>Cancel</button>
|
||||
@@ -336,6 +769,111 @@
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
input.url {
|
||||
width: 100%;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 11px 12px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
}
|
||||
input.url:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* The local door's picked-file line (B-flow panel). */
|
||||
.srcfile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.srcfile .fname {
|
||||
font-family: Consolas, monospace;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.srcfile .flen {
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
textarea.tl {
|
||||
min-height: 170px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 7px;
|
||||
}
|
||||
.toolline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.toolok {
|
||||
color: #3ecf7e;
|
||||
}
|
||||
.toolsep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.linkbtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.linkbtn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.linkbtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.scanbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 42px 0 30px;
|
||||
}
|
||||
.scanline {
|
||||
font-size: 13.5px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.scanhit {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ring {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.srcline {
|
||||
display: flex;
|
||||
@@ -376,6 +914,9 @@
|
||||
.counts .ok {
|
||||
color: #3ecf7e;
|
||||
}
|
||||
.counts .st {
|
||||
color: #58b6e8;
|
||||
}
|
||||
.counts .un {
|
||||
color: #e8a33d;
|
||||
}
|
||||
@@ -465,6 +1006,13 @@
|
||||
background: rgba(62, 207, 126, 0.13);
|
||||
color: #3ecf7e;
|
||||
}
|
||||
.tag.st {
|
||||
background: rgba(88, 182, 232, 0.13);
|
||||
color: #58b6e8;
|
||||
}
|
||||
.tag.dim {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.tag.un {
|
||||
background: rgba(232, 163, 61, 0.15);
|
||||
color: #e8a33d;
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
// rail, track rows, Now Playing). Callers hand a THUNK that produces the
|
||||
// rows when a target is picked, so building the payload costs nothing
|
||||
// until the user actually commits.
|
||||
//
|
||||
// The open menu is PORTALED to <body> and positioned fixed (the SaveMenu
|
||||
// idiom): triggers live inside transformed hover overlays (.rowbtns,
|
||||
// .rowact) and glass headers, and a transform or backdrop-filter ancestor
|
||||
// both traps z-index and re-anchors position:fixed — the third
|
||||
// menu-trapped-in-a-context episode made this the standing rule. Scoped
|
||||
// styles still apply to the portaled node (the hash class travels with it).
|
||||
import { playlistStore, refreshPlaylists } from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
@@ -10,15 +17,22 @@
|
||||
let {
|
||||
// () => [{ title, artist, album, durationSecs, path, artPath }]
|
||||
tracks,
|
||||
align = "left", // "left" | "right"
|
||||
align = "left", // preferred horizontal edge: "left" | "right" of trigger
|
||||
iconSize = 13,
|
||||
title = "Add to playlist",
|
||||
compact = false, // icon-only trigger
|
||||
exclude = null, // playlist id to omit (a playlist's own rows)
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let busy = $state(false);
|
||||
let doneFor = $state(null); // playlist id that just accepted rows
|
||||
let triggerEl = $state(null);
|
||||
let menuEl = null;
|
||||
|
||||
const targets = $derived(
|
||||
playlistStore.playlists.filter((pl) => pl.id !== exclude),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !playlistStore.loaded) refreshPlaylists();
|
||||
@@ -30,6 +44,56 @@
|
||||
doneFor = null;
|
||||
}
|
||||
|
||||
/** Portal the menu to <body>, then place it against the trigger: below
|
||||
* and edge-aligned, flipped above when the viewport runs out. Measured
|
||||
* after mount so the content-driven width is what gets clamped. */
|
||||
function overlay(node) {
|
||||
document.body.appendChild(node);
|
||||
const r = triggerEl.getBoundingClientRect();
|
||||
const mw = node.offsetWidth;
|
||||
const mh = node.offsetHeight;
|
||||
let left = align === "right" ? r.right - mw : r.left;
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
|
||||
let top = r.bottom + 7;
|
||||
if (top + mh > window.innerHeight - 8) top = Math.max(8, r.top - 7 - mh);
|
||||
node.style.top = `${top}px`;
|
||||
node.style.left = `${left}px`;
|
||||
node.style.visibility = "visible";
|
||||
return {
|
||||
destroy() {
|
||||
node.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Close on outside click, Escape, resize, or any scroll OUTSIDE the menu
|
||||
// (the playlist list scrolls inside it) — a fixed menu must not drift away
|
||||
// from its trigger.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e) => {
|
||||
if (menuEl?.contains(e.target) || triggerEl?.contains(e.target)) return;
|
||||
open = false;
|
||||
};
|
||||
const onScroll = (e) => {
|
||||
if (menuEl && (e.target === menuEl || menuEl.contains(e.target))) return;
|
||||
open = false;
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") open = false;
|
||||
};
|
||||
document.addEventListener("click", onDoc, true);
|
||||
document.addEventListener("scroll", onScroll, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("resize", onScroll);
|
||||
return () => {
|
||||
document.removeEventListener("click", onDoc, true);
|
||||
document.removeEventListener("scroll", onScroll, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
};
|
||||
});
|
||||
|
||||
function payload() {
|
||||
return (tracks() ?? []).map((t) => ({
|
||||
title: t.title ?? "",
|
||||
@@ -38,6 +102,11 @@
|
||||
duration_secs: t.durationSecs ?? t.duration_secs ?? null,
|
||||
path: t.path ?? null,
|
||||
art_path: t.artPath ?? t.art_path ?? null,
|
||||
// A pathless row is a STREAM row; these anchor it — the catalog
|
||||
// track id for identity, the cover URL for artwork it has no
|
||||
// local file to read from.
|
||||
track_id: t.trackId != null ? String(t.trackId) : (t.track_id ?? null),
|
||||
art_url: t.artUrl ?? t.art_url ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -84,16 +153,11 @@
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function windowClick() {
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={open ? windowClick : undefined} />
|
||||
|
||||
<div class="plm">
|
||||
<div class="plm" onkeydown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
class="plm-trigger"
|
||||
class:compact
|
||||
{title}
|
||||
@@ -104,31 +168,39 @@
|
||||
<Icon name="list-plus" size={iconSize} />
|
||||
{#if !compact}<span>Add to playlist</span>{/if}
|
||||
</button>
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<div class="plm-menu" class:right={align === "right"} role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="plm-item new" onclick={addToNew} disabled={busy}>
|
||||
<Icon name="plus" size={14} /> New playlist
|
||||
</button>
|
||||
{#if playlistStore.playlists.length}
|
||||
<div class="plm-rule"></div>
|
||||
<div class="plm-list">
|
||||
{#each playlistStore.playlists as pl (pl.id)}
|
||||
<button class="plm-item" onclick={() => addTo(pl)} disabled={busy}>
|
||||
{#if doneFor === pl.id}
|
||||
<span class="ok"><Icon name="check" size={14} /></span>
|
||||
{:else}
|
||||
<Icon name="playlist" size={14} />
|
||||
{/if}
|
||||
<span class="nm">{pl.name}</span>
|
||||
<span class="ct">{pl.track_count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="plm-menu"
|
||||
bind:this={menuEl}
|
||||
use:overlay
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button class="plm-item new" onclick={addToNew} disabled={busy}>
|
||||
<Icon name="plus" size={14} /> New playlist
|
||||
</button>
|
||||
{#if targets.length}
|
||||
<div class="plm-rule"></div>
|
||||
<div class="plm-list">
|
||||
{#each targets as pl (pl.id)}
|
||||
<button class="plm-item" onclick={() => addTo(pl)} disabled={busy}>
|
||||
{#if doneFor === pl.id}
|
||||
<span class="ok"><Icon name="check" size={14} /></span>
|
||||
{:else}
|
||||
<Icon name="playlist" size={14} />
|
||||
{/if}
|
||||
<span class="nm">{pl.name}</span>
|
||||
<span class="ct">{pl.track_count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.plm {
|
||||
@@ -145,23 +217,20 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
/* Fixed positioning escapes overflow clipping and every stacking context;
|
||||
placed (and made visible) by the overlay action once measured. */
|
||||
.plm-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 7px);
|
||||
left: 0;
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
min-width: 210px;
|
||||
max-width: 270px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
z-index: 80;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.plm-menu.right {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
.plm-list {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script>
|
||||
// The listen's presence while the import modal is closed — the import
|
||||
// toast's dark glass pill, one berth higher so both can show at once.
|
||||
// Running: live stage + window count + a cancel. Done: click to open
|
||||
// the review. Error: what happened + dismiss.
|
||||
import { goToView } from "../lib/state.svelte.js";
|
||||
import { setlistScan, dismissScan } from "../lib/setlistScan.svelte.js";
|
||||
|
||||
const label = $derived.by(() => {
|
||||
if (setlistScan.error) return setlistScan.error;
|
||||
if (setlistScan.draft) {
|
||||
const n = setlistScan.draft.entries?.length ?? 0;
|
||||
return `${setlistScan.label} — ${n} row${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
switch (setlistScan.stage) {
|
||||
case "tool":
|
||||
return "Fetching yt-dlp…";
|
||||
case "runtime":
|
||||
return "Fetching deno…";
|
||||
case "audio":
|
||||
return `Fetching the set's audio…${
|
||||
setlistScan.pct != null ? ` ${Math.round(setlistScan.pct)}%` : ""
|
||||
}`;
|
||||
case "decode":
|
||||
return "Decoding the set…";
|
||||
case "listen":
|
||||
return `Listening… ${setlistScan.window} of ${setlistScan.total || "?"}`;
|
||||
default:
|
||||
return "Listening…";
|
||||
}
|
||||
});
|
||||
|
||||
function review() {
|
||||
setlistScan.wantsReview = true;
|
||||
goToView("playlists");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if setlistScan.active}
|
||||
<div class="layer">
|
||||
<div class="toast" class:err={!!setlistScan.error}>
|
||||
{#if setlistScan.draft}
|
||||
<button class="body" onclick={review} title="Open the review">
|
||||
<span class="mark ok">✓</span>
|
||||
<span>{label} — <b>review</b></span>
|
||||
</button>
|
||||
<button
|
||||
class="x"
|
||||
onclick={dismissScan}
|
||||
title="Dismiss — the hearing is remembered, a re-listen is instant"
|
||||
>×</button>
|
||||
{:else if setlistScan.error}
|
||||
<span class="body">
|
||||
<span class="mark warn">!</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<button class="x" onclick={dismissScan} title="Dismiss">×</button>
|
||||
{:else}
|
||||
<span class="body">
|
||||
<span class="mark pulse">▸</span>
|
||||
<span>{label}</span>
|
||||
{#if setlistScan.stage === "listen" && setlistScan.hit}
|
||||
<span class="hit">{setlistScan.hit}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button class="x" onclick={dismissScan} title="Cancel the listen">×</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* The import toast's berth, one pill higher, so both can show at once. */
|
||||
.layer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--player-height, 62px) + 8px + 26px + 44px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px 5px 16px;
|
||||
border-radius: 99px;
|
||||
background: rgba(22, 23, 28, 0.94);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
animation: chip-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.toast.err {
|
||||
border-color: rgba(240, 205, 119, 0.4);
|
||||
}
|
||||
.body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 4px 8px 4px 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
max-width: 520px;
|
||||
}
|
||||
button.body {
|
||||
cursor: pointer;
|
||||
border-radius: 99px;
|
||||
}
|
||||
button.body:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.hit {
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
}
|
||||
.x {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.x:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.mark {
|
||||
flex: none;
|
||||
font-style: normal;
|
||||
}
|
||||
.mark.pulse {
|
||||
color: var(--accent);
|
||||
animation: chip-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chip-pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
.mark.ok {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mark.warn {
|
||||
color: #f0cd77;
|
||||
}
|
||||
b {
|
||||
font-weight: 800;
|
||||
}
|
||||
@keyframes chip-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
.mark.pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+42
-4
@@ -73,7 +73,8 @@ export const showInFolder = (path) => invoke("show_in_folder", { path });
|
||||
export const refreshArtistArt = () => invoke("refresh_artist_art");
|
||||
export const playStream = (username, path, size, duration) =>
|
||||
invoke("play_stream", { username, path, size, duration });
|
||||
export const playLocal = (path, duration) => invoke("play_local", { path, duration });
|
||||
export const playLocal = (path, duration, startSecs = null) =>
|
||||
invoke("play_local", { path, duration, startSecs });
|
||||
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
|
||||
export const playerControl = (action, value, rate) =>
|
||||
invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
|
||||
@@ -81,7 +82,8 @@ export const playerControl = (action, value, rate) =>
|
||||
// playback from events, so after a reload this is the only way to find out that
|
||||
// audio is still running — see `restoreSession`.
|
||||
export const playerState = () => invoke("player_state");
|
||||
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
|
||||
export const waveform = (path, buckets, startSecs = null, endSecs = null) =>
|
||||
invoke("waveform", { path, buckets, startSecs, endSecs });
|
||||
export const libraryAlbums = () => invoke("library_albums");
|
||||
export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs });
|
||||
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
|
||||
@@ -170,6 +172,8 @@ export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
|
||||
durationSecs: fields.durationSecs ?? null,
|
||||
path: fields.path ?? null,
|
||||
artPath: fields.artPath ?? null,
|
||||
trackId: fields.trackId ?? null,
|
||||
artUrl: fields.artUrl ?? null,
|
||||
});
|
||||
// Cover as a data URL: custom master, else composed mosaic, else null.
|
||||
export const playlistCover = (id, maxEdge = null) =>
|
||||
@@ -181,11 +185,44 @@ export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id })
|
||||
export const playlistImportProbe = () => invoke("playlist_import_probe");
|
||||
export const playlistImportFolder = () => invoke("playlist_import_folder");
|
||||
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
|
||||
export const playlistImportCommit = (name, description, kind, entries) =>
|
||||
invoke("playlist_import_commit", { name, description: description ?? null, kind, entries });
|
||||
export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
|
||||
// The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a
|
||||
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
|
||||
export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url });
|
||||
// The local door is split pick/scan so the modal can collect an optional
|
||||
// pasted tracklist between the two.
|
||||
export const playlistPickSetFile = () => invoke("playlist_pick_set_file");
|
||||
export const playlistScanFile = (path, tracklist = null) =>
|
||||
invoke("playlist_scan_file", { path, tracklist });
|
||||
export const playlistScanCancel = () => invoke("playlist_scan_cancel");
|
||||
export const ytdlpStatus = () => invoke("ytdlp_status");
|
||||
export const ytdlpDownload = () => invoke("ytdlp_download");
|
||||
export const denoDownload = () => invoke("deno_download");
|
||||
export const playlistImportCommit = (name, description, kind, entries, coverUrl = null) =>
|
||||
invoke("playlist_import_commit", {
|
||||
name,
|
||||
description: description ?? null,
|
||||
kind,
|
||||
entries,
|
||||
coverUrl,
|
||||
});
|
||||
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
|
||||
// the written path, or null when the dialog was cancelled.
|
||||
export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
|
||||
|
||||
// ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ----------
|
||||
export const lyricModelStatus = () => invoke("lyric_model_status");
|
||||
export const lyricModelDownload = () => invoke("lyric_model_download");
|
||||
// Vocal separation model (src-tauri/src/vocal_sep.rs) — the ctc-v5 stem tier.
|
||||
export const vocalSepStatus = () => invoke("vocal_sep_status");
|
||||
export const vocalSepDownload = () => invoke("vocal_sep_download");
|
||||
export const lyricPrepRequest = (path, durationSecs, lines, stream = null) =>
|
||||
invoke("lyric_prep_request", {
|
||||
path: path ?? null,
|
||||
stream,
|
||||
durationSecs: durationSecs ?? null,
|
||||
lines,
|
||||
});
|
||||
// Local-library candidates for the unresolved-row repair panel.
|
||||
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
|
||||
|
||||
@@ -220,6 +257,7 @@ export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { p
|
||||
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
|
||||
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
|
||||
export const catalogSearch = (query) => invoke("catalog_search", { query });
|
||||
export const catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc });
|
||||
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
||||
invoke("remember_fused_artist_portrait", { artistId, portrait });
|
||||
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
|
||||
|
||||
Binary file not shown.
@@ -59,6 +59,7 @@ const ROOTS = new Set([
|
||||
"library",
|
||||
"downloads",
|
||||
"settings",
|
||||
"playlists",
|
||||
"catalog",
|
||||
]);
|
||||
|
||||
@@ -248,6 +249,18 @@ export function trail() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// A playlist sits under the Playlists shelf the same way. The shelf is a
|
||||
// root, so when the page was reached through it the origin crumb already
|
||||
// says Playlists; this branch covers the direct arrivals (a pinned
|
||||
// playlist in the sidebar) by borrowing the shelf from history or
|
||||
// synthesising it.
|
||||
if (here.view === "playlist" && nav.origin?.view !== "playlists") {
|
||||
const known = [...nav.stack].reverse().find((e) => e.view === "playlists");
|
||||
crumbs.push(
|
||||
known ?? { view: "playlists", label: "Playlists", glyph: "playlist", key: "playlists" },
|
||||
);
|
||||
}
|
||||
crumbs.push(here);
|
||||
return crumbs;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Background DJ-set listening. A scan OUTLIVES the import modal: starting a
|
||||
// listen closes the modal, progress rides a global chip above the player
|
||||
// bar, the app stays fully usable, and the finished draft waits on the chip
|
||||
// until it is reviewed or dismissed. One scan at a time — the backend's
|
||||
// cancel flag is global by design.
|
||||
import * as api from "./api.js";
|
||||
|
||||
export const setlistScan = $state({
|
||||
active: false,
|
||||
mode: null, // "youtube" | "file"
|
||||
label: "",
|
||||
stage: null, // "tool" | "runtime" | "audio" | "decode" | "listen"
|
||||
window: 0,
|
||||
total: 0,
|
||||
pct: null,
|
||||
hit: null,
|
||||
draft: null, // finished ImportDraft awaiting review
|
||||
error: null,
|
||||
wantsReview: false, // chip clicked; the Playlists view consumes this
|
||||
});
|
||||
|
||||
let subscribed = false;
|
||||
function subscribe() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
api.onEvent("playlist:scan-progress", (p) => {
|
||||
// A ready chip must not be mutated by stray late events.
|
||||
if (!setlistScan.active || setlistScan.draft || !p?.stage) return;
|
||||
setlistScan.stage = p.stage;
|
||||
if (p.window != null) setlistScan.window = p.window;
|
||||
if (p.total != null) setlistScan.total = p.total;
|
||||
setlistScan.pct = p.pct ?? null;
|
||||
if (p.hit != null) setlistScan.hit = p.hit;
|
||||
});
|
||||
}
|
||||
|
||||
async function run(mode, label, call) {
|
||||
subscribe();
|
||||
Object.assign(setlistScan, {
|
||||
active: true,
|
||||
mode,
|
||||
label,
|
||||
stage: mode === "youtube" ? "audio" : "decode",
|
||||
window: 0,
|
||||
total: 0,
|
||||
pct: null,
|
||||
hit: null,
|
||||
draft: null,
|
||||
error: null,
|
||||
wantsReview: false,
|
||||
});
|
||||
try {
|
||||
const draft = await call();
|
||||
if (draft) {
|
||||
setlistScan.draft = draft;
|
||||
setlistScan.label = draft.name ?? label;
|
||||
} else {
|
||||
// Defensive: a null draft must not wedge the chip.
|
||||
setlistScan.active = false;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = String(e);
|
||||
if (message.includes("canceled")) setlistScan.active = false;
|
||||
else setlistScan.error = message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Kick a YouTube listen into the background. False = one already runs. */
|
||||
export function startYoutubeListen(url) {
|
||||
if (setlistScan.active) return false;
|
||||
void run("youtube", "YouTube set", () => api.playlistScanYoutube(url));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Listen to an already-picked set recording in the background, with an
|
||||
* optionally pasted tracklist riding along. False = one already runs. */
|
||||
export function startFileListen(path, tracklist = null, label = "Set recording") {
|
||||
if (setlistScan.active) return false;
|
||||
void run("file", label, () => api.playlistScanFile(path, tracklist));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Hand the finished draft to the review and clear the chip. */
|
||||
export function takeScanDraft() {
|
||||
const draft = setlistScan.draft;
|
||||
setlistScan.draft = null;
|
||||
setlistScan.active = false;
|
||||
setlistScan.wantsReview = false;
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Cancel a running scan, or drop a finished/failed one unseen. */
|
||||
export function dismissScan() {
|
||||
if (setlistScan.active && !setlistScan.draft && !setlistScan.error) {
|
||||
void api.playlistScanCancel();
|
||||
}
|
||||
setlistScan.active = false;
|
||||
setlistScan.draft = null;
|
||||
setlistScan.error = null;
|
||||
setlistScan.wantsReview = false;
|
||||
}
|
||||
+275
-27
@@ -1624,7 +1624,9 @@ const VIEW_LABELS = {
|
||||
library: "Your Library",
|
||||
downloads: "Downloads",
|
||||
settings: "Settings",
|
||||
playlists: "Playlists",
|
||||
playlist: "Playlist",
|
||||
lyrics: "Lyrics",
|
||||
};
|
||||
const VIEW_GLYPHS = {
|
||||
home: "home",
|
||||
@@ -1632,7 +1634,9 @@ const VIEW_GLYPHS = {
|
||||
library: "library",
|
||||
downloads: "download",
|
||||
settings: "settings",
|
||||
playlists: "playlist",
|
||||
playlist: "music",
|
||||
lyrics: "music",
|
||||
};
|
||||
|
||||
/** Re-open a recorded place. The one function the history arrows and the trail
|
||||
@@ -3384,10 +3388,37 @@ export async function saveStream(scope) {
|
||||
|
||||
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
|
||||
function currentArtistAlbum() {
|
||||
const item = player.queue[player.queueIndex] ?? null;
|
||||
// A playlist item's `sub` is the PLAYLIST's name, never "artist — album",
|
||||
// so its own fields are the whole identity — splitting would offer the
|
||||
// playlist name as an artist (and negative-cache the click into silence).
|
||||
if (item?.plItemId != null) return [item.artist ?? "", item.album ?? ""];
|
||||
const sub = player.track?.sub ?? "";
|
||||
return sub.includes(" — ")
|
||||
const split = sub.includes(" — ")
|
||||
? sub.split(" — ").map((s) => s.trim())
|
||||
: [sub.trim(), ""];
|
||||
// Per field, the item's own artist/album win — the player bar's rule.
|
||||
return [item?.artist ?? split[0] ?? "", item?.album ?? split[1] ?? ""];
|
||||
}
|
||||
|
||||
/** Anchor-exact album jump for a row that knows its catalog track id but not
|
||||
* its record (playlist stream rows from before commits stored albums): one
|
||||
* name search, then the ID picks the track — never a name guess. */
|
||||
async function openAlbumForCurrentTrackId(opts = {}) {
|
||||
const item = player.queue[player.queueIndex] ?? null;
|
||||
const trackId = item?.trackId;
|
||||
if (trackId == null || !item?.artist || !item?.title) return false;
|
||||
try {
|
||||
const res = await api.catalogSearch(`${item.artist} ${item.title}`);
|
||||
const hit = res?.tracks?.find((t) => Number(t.id) === Number(trackId));
|
||||
if (hit?.album_id != null) {
|
||||
openCatalogAlbum(hit.album_id, { trackId, ...opts });
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the name path
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Now-playing meta links: jump to the artist / album / song's catalog page. */
|
||||
@@ -3395,15 +3426,16 @@ export function openCurrentArtist() {
|
||||
const [artist] = currentArtistAlbum();
|
||||
if (artist) openArtistByName(artist);
|
||||
}
|
||||
export function openCurrentAlbum() {
|
||||
export async function openCurrentAlbum() {
|
||||
if (player.catalogRef.albumId != null) {
|
||||
openCatalogAlbum(player.catalogRef.albumId);
|
||||
return;
|
||||
}
|
||||
if (await openAlbumForCurrentTrackId()) return;
|
||||
const [artist, album] = currentArtistAlbum();
|
||||
if (album) openAlbumByName(artist, album);
|
||||
}
|
||||
export function openCurrentSong() {
|
||||
export async function openCurrentSong() {
|
||||
const trackTitle = player.track?.title;
|
||||
if (player.catalogRef.albumId != null) {
|
||||
openCatalogAlbum(player.catalogRef.albumId, {
|
||||
@@ -3412,6 +3444,7 @@ export function openCurrentSong() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (await openAlbumForCurrentTrackId({ trackTitle })) return;
|
||||
const [artist, album] = currentArtistAlbum();
|
||||
if (album) openAlbumByName(artist, album, { trackTitle });
|
||||
}
|
||||
@@ -3827,6 +3860,18 @@ async function cachedStreamSourceFor(trackId, artist, title) {
|
||||
for (const key of streamSourceKeys(trackId, artist, title)) {
|
||||
const src = map.get(key);
|
||||
if (!src) continue;
|
||||
// A remembered file whose NAME does not even contain the track's
|
||||
// base title is a mis-paired memory (the 2026-08-20 incident:
|
||||
// three unrelated rows each "remembered" the same Weezer FLAC and
|
||||
// played it from the warm cache forever). Delete it and resolve
|
||||
// normally — a wrongly-dropped legit source only costs one search.
|
||||
const base = normText(asciiFold((title ?? "").split(/[([]/)[0]));
|
||||
const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? ""));
|
||||
if (base.length >= 4 && fileName && !fileName.includes(base)) {
|
||||
map.delete(key);
|
||||
saveStreamSources();
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (await api.streamCacheReady(src.username, src.path, src.size)) return src;
|
||||
} catch {
|
||||
@@ -5542,7 +5587,7 @@ export function initEvents() {
|
||||
// the reader back where they were. Taken exactly once, so an
|
||||
// ordinary resume later in the same track is unaffected.
|
||||
const seekTo = takeResumeSeek();
|
||||
if (seekTo != null) api.playerControl("seek", seekTo);
|
||||
if (seekTo != null) seekPlayer(seekTo);
|
||||
break;
|
||||
}
|
||||
case "paused":
|
||||
@@ -5559,7 +5604,19 @@ export function initEvents() {
|
||||
if (ev.gapless) advanceGaplessly();
|
||||
else nextTrack(true);
|
||||
break;
|
||||
case "position":
|
||||
case "position": {
|
||||
// A set-recording slice shows slice-relative time, keeps its
|
||||
// own duration (the engine reports the whole set's), and
|
||||
// cuts to the next row at its end boundary.
|
||||
const seg = player.queue[player.queueIndex]?.segment;
|
||||
if (seg) {
|
||||
const rel = Math.max(0, ev.secs - seg.start);
|
||||
latestPlayerPosition = rel;
|
||||
if (!player.scrubbing && ui.windowActive) player.position = rel;
|
||||
listenTick(rel);
|
||||
if (seg.end != null && ev.secs >= seg.end) nextTrack(true);
|
||||
break;
|
||||
}
|
||||
latestPlayerPosition = ev.secs;
|
||||
if (ev.duration != null) latestPlayerDuration = ev.duration;
|
||||
if (!player.scrubbing && ui.windowActive) player.position = ev.secs;
|
||||
@@ -5568,6 +5625,7 @@ export function initEvents() {
|
||||
}
|
||||
listenTick(ev.secs);
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
player.status = "idle";
|
||||
player.error = ev.message;
|
||||
@@ -5593,8 +5651,7 @@ export function initEvents() {
|
||||
// Fully cached → final waveform, and the peer's slot is
|
||||
// definitely free, so make sure the next track is pre-buffering.
|
||||
if (cur?.stream) loadStreamWaveform(cur, true);
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
prepareFollowing();
|
||||
} else if (
|
||||
ui.windowActive
|
||||
&& cur?.stream
|
||||
@@ -5823,22 +5880,33 @@ export async function playLocalFile(path, title, subLabel, artPath = null) {
|
||||
}
|
||||
|
||||
/** Play an album/playlist (list of library tracks) starting at `start`.
|
||||
* `artPath` is the album cover; playlist tracks carry their own art_path. */
|
||||
* `artPath` is the album cover; playlist tracks carry their own art_path.
|
||||
*
|
||||
* A row with `needsResolve` is a playlist STREAM row: full identity, no
|
||||
* source yet. It queues as a pending item that resolves through the normal
|
||||
* ladder when reached (or by the look-ahead a track early), so Play on a
|
||||
* mixed playlist keeps every row in order instead of skipping the streams. */
|
||||
export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
|
||||
autoAlbum = null;
|
||||
player.queue = tracks.map((t) => ({
|
||||
path: t.path,
|
||||
path: t.path ?? null,
|
||||
title: t.title,
|
||||
sub: subLabel,
|
||||
artist: t.artist ?? null,
|
||||
album: t.album ?? null,
|
||||
duration: t.duration_secs ?? null,
|
||||
art: t.art_path ?? artPath,
|
||||
quality: localQuality(t),
|
||||
art: t.art_path ?? (t.needsResolve ? null : artPath),
|
||||
artUrl: t.artUrl ?? null,
|
||||
trackId: t.trackId ?? null,
|
||||
quality: t.path ? localQuality(t) : null,
|
||||
// Which playlist OCCURRENCE this queue item came from, so the playlist
|
||||
// page highlights exactly the row that is playing — a recording that
|
||||
// appears twice must light up once. Null for album playback.
|
||||
plItemId: t.plItemId ?? null,
|
||||
// A set-recording SLICE: {start, end} seconds into the file at
|
||||
// `path` (end null = to the file's end). The playback layer seeks
|
||||
// in at start and cuts at end; display time is slice-relative.
|
||||
segment: t.segment ?? null,
|
||||
}));
|
||||
player.catalogRef = { albumId: null, trackId: null };
|
||||
playQueueIndex(start);
|
||||
@@ -5863,6 +5931,15 @@ function playQueueIndex(i) {
|
||||
};
|
||||
player.status = "buffering";
|
||||
player.duration = item.duration;
|
||||
// A playlist stream row queued without a source yet: resolve it now, then
|
||||
// re-enter with the source attached. The item mutates in place, so a
|
||||
// repeat visit (prev/next, repeat-all) is instant.
|
||||
if (!item.path && !item.stream) {
|
||||
player.track.source = "stream";
|
||||
player.streamTotal = 0;
|
||||
void resolvePendingQueueItem(i, item);
|
||||
return;
|
||||
}
|
||||
if (item.stream) {
|
||||
// Remote track streamed from a peer while it downloads to cache.
|
||||
rememberStreamSource(item);
|
||||
@@ -5881,8 +5958,7 @@ function playQueueIndex(i) {
|
||||
.then(() => {
|
||||
// Ordered behind play_stream so the gapless append can't
|
||||
// race the generation bump on the audio thread.
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
prepareFollowing();
|
||||
})
|
||||
.catch((e) => {
|
||||
player.error = String(e);
|
||||
@@ -5891,12 +5967,27 @@ function playQueueIndex(i) {
|
||||
return;
|
||||
}
|
||||
player.streamTotal = 0;
|
||||
if (item.segment) {
|
||||
// A set-recording slice: the lane shows the SLICE's own waveform (a
|
||||
// ranged decode — a 90-minute skyline says nothing about a 4-minute
|
||||
// stretch), and the decoder comes up at the slice start. The early
|
||||
// cut at segment.end is enforced by the position handler.
|
||||
loadWaveform(item.path, item.segment);
|
||||
setAccentFromArt(item.art ?? null);
|
||||
api.playLocal(item.path, item.duration, item.segment.start).catch((e) => {
|
||||
player.error = String(e);
|
||||
});
|
||||
// Slices skip the gapless arms (gaplessNextIndex refuses them) but
|
||||
// a pending row after one still resolves a track early.
|
||||
prepareFollowing();
|
||||
return;
|
||||
}
|
||||
loadWaveform(item.path);
|
||||
setAccentFromArt(item.art ?? null);
|
||||
api.playLocal(item.path, item.duration).catch((e) => {
|
||||
player.error = String(e);
|
||||
});
|
||||
preloadFollowingTrack();
|
||||
prepareFollowing();
|
||||
}
|
||||
|
||||
/** Play a specific queue position (Now Playing panel track clicks). */
|
||||
@@ -5910,14 +6001,23 @@ function gaplessNextIndex() {
|
||||
if (player.shuffle) return -1;
|
||||
if (player.repeat === "one") return player.queueIndex;
|
||||
const next = player.queueIndex + 1;
|
||||
return next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
|
||||
const i = next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
|
||||
// Set-recording slices never join the gapless machinery: a preload or
|
||||
// append would come up at the FILE's start, not the slice's, and the
|
||||
// slice's early cut is driven by the position handler, not the
|
||||
// engine's track-ended.
|
||||
if (player.queue[player.queueIndex]?.segment || (i >= 0 && player.queue[i]?.segment)) {
|
||||
return -1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function preloadFollowingTrack() {
|
||||
const next = gaplessNextIndex();
|
||||
if (next < 0) return;
|
||||
const item = player.queue[next];
|
||||
if (item.stream) return; // gapless preload is local-only
|
||||
// Gapless preload is local-only; a pending stream row has no path at all.
|
||||
if (item.stream || !item.path) return;
|
||||
api.preloadLocal(item.path, item.duration).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -5967,6 +6067,21 @@ async function tryAppendNext() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Arm every next-track preparation that applies: a LOCAL next preloads
|
||||
* into the sink, a resolved STREAM next pre-buffers and offers the
|
||||
* gapless append, and a PENDING playlist row resolves a track early so
|
||||
* it can join the same chain. Each is a cheap no-op when it does not
|
||||
* apply — call this at every track start and every gapless boundary,
|
||||
* whatever the current source is. (The playlist lesson: only the
|
||||
* album-shaped paths were armed, so a mixed playlist went seamless for
|
||||
* exactly one boundary and stalled on every one after it.) */
|
||||
function prepareFollowing() {
|
||||
preloadFollowingTrack();
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
scheduleQueueLookahead();
|
||||
}
|
||||
|
||||
function advanceGaplessly() {
|
||||
const next = gaplessNextIndex();
|
||||
// The backend only labels a boundary gapless when it has this source
|
||||
@@ -6000,15 +6115,14 @@ function advanceGaplessly() {
|
||||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||||
setAccentFromArtUrl(item.artUrl ?? null);
|
||||
loadStreamWaveform(item);
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
prepareFollowing();
|
||||
scheduleAlbumFill();
|
||||
return;
|
||||
}
|
||||
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
|
||||
loadWaveform(item.path);
|
||||
setAccentFromArt(item.art ?? null);
|
||||
preloadFollowingTrack();
|
||||
prepareFollowing();
|
||||
}
|
||||
|
||||
// ---- album continuation ----
|
||||
@@ -6164,6 +6278,118 @@ async function maybeExtendAlbumQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pending playlist stream rows -------------------------------------------
|
||||
// A mixed playlist queues its stream rows as PENDING items — identity, no
|
||||
// source. They resolve through the same ladder the album fill uses: the
|
||||
// remembered-source memory first (free), then the sanitized search. One
|
||||
// resolution is in flight per item, shared between play-on-reach and the
|
||||
// look-ahead, so hammering next can't stack searches for one row.
|
||||
|
||||
const pendingResolves = new WeakMap();
|
||||
|
||||
/** Resolve one pending descriptor to a playable source, or null. */
|
||||
async function resolveStreamSourceFor(t, budgetMs = 7000) {
|
||||
const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title);
|
||||
if (known) {
|
||||
return {
|
||||
path: known.path,
|
||||
duration: known.duration ?? t.duration ?? null,
|
||||
stream: { username: known.username, size: known.size },
|
||||
quality: known.quality ?? null,
|
||||
};
|
||||
}
|
||||
if (app.connection !== "online") return null;
|
||||
const goodCount = (rs) =>
|
||||
songCandidates(rs, t.title, true, t.album ?? null, t.duration ?? null, t.artist).length;
|
||||
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
|
||||
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
|
||||
let artistIncluded = true;
|
||||
let responses = primary ? await collectSources(primary, budgetMs, goodCount) : [];
|
||||
if (!responses.length && fallback && fallback !== primary) {
|
||||
responses = await collectSources(fallback, budgetMs, goodCount);
|
||||
artistIncluded = normText(fallback).includes(normText(t.artist ?? ""));
|
||||
} else if (primary) {
|
||||
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
|
||||
}
|
||||
const best = songCandidates(
|
||||
responses, t.title, true, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
|
||||
)[0];
|
||||
if (!best) return null;
|
||||
return {
|
||||
path: best.file.path,
|
||||
duration: best.file.duration ?? t.duration ?? null,
|
||||
stream: { username: best.username, size: best.file.size },
|
||||
quality: qualityOf(best.file),
|
||||
};
|
||||
}
|
||||
|
||||
/** Single-flight source resolution for a queue item; mutates it in place. */
|
||||
function resolveQueueItem(item) {
|
||||
const inFlight = pendingResolves.get(item);
|
||||
if (inFlight) return inFlight;
|
||||
const p = resolveStreamSourceFor(item)
|
||||
.then((src) => {
|
||||
if (src) Object.assign(item, src);
|
||||
return src;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => pendingResolves.delete(item));
|
||||
pendingResolves.set(item, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Two sourceless rows in a row is a signal (a whole stretch that cannot
|
||||
// resolve), and auto-advancing through a long dead playlist would fire one
|
||||
// Soulseek search per row — the exact flood the album fill's delay exists to
|
||||
// prevent. The streak resets on any success.
|
||||
let pendingFailStreak = 0;
|
||||
|
||||
async function resolvePendingQueueItem(i, item) {
|
||||
const src = await resolveQueueItem(item);
|
||||
// The world may have moved on mid-search: queue replaced, or the user is
|
||||
// on another row. An enriched item keeps its source either way.
|
||||
if (player.queue[i] !== item || player.queueIndex !== i) return;
|
||||
if (src) {
|
||||
pendingFailStreak = 0;
|
||||
playQueueIndex(i);
|
||||
return;
|
||||
}
|
||||
item.unplayable = true;
|
||||
player.error = `No streamable sources found for “${item.title}”`;
|
||||
pendingFailStreak += 1;
|
||||
if (pendingFailStreak >= 2 || player.queue.length <= 1) {
|
||||
pendingFailStreak = 0;
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
nextTrack(true);
|
||||
}
|
||||
|
||||
// Resolve the NEXT pending row a track early, on the album fill's own
|
||||
// anti-flood delay, so the boundary into a stream row starts instantly
|
||||
// instead of stalling on a live search.
|
||||
let queueLookaheadTimer = null;
|
||||
function scheduleQueueLookahead() {
|
||||
clearTimeout(queueLookaheadTimer);
|
||||
queueLookaheadTimer = setTimeout(() => {
|
||||
queueLookaheadTimer = null;
|
||||
void resolveNextPendingItem();
|
||||
}, ALBUM_FILL_DELAY_MS);
|
||||
}
|
||||
|
||||
async function resolveNextPendingItem() {
|
||||
if (player.shuffle) return; // the next index is unknowable
|
||||
const next = player.queueIndex + 1;
|
||||
const item = player.queue[next];
|
||||
if (!item || item.path || item.stream || item.unplayable) return;
|
||||
const src = await resolveQueueItem(item);
|
||||
if (!src || !player.queue.includes(item)) return;
|
||||
// Now a normal upcoming stream: pre-buffer it and offer the gapless
|
||||
// append, exactly as if it had been queued with its source.
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
}
|
||||
|
||||
// ---- waveform ----
|
||||
|
||||
// 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so
|
||||
@@ -6177,24 +6403,32 @@ let waveReqPath = null;
|
||||
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
|
||||
let waveRefreshAt = 0;
|
||||
|
||||
async function loadWaveform(path) {
|
||||
async function loadWaveform(path, range = null) {
|
||||
player.waveform = [];
|
||||
player.wavePending = !!path;
|
||||
waveReqPath = path;
|
||||
// A range = a set-recording slice's lane; each slice keys its own
|
||||
// request so two slices of one file never answer for each other.
|
||||
const req = path && range ? `${path}@${range.start}-${range.end ?? "end"}` : path;
|
||||
waveReqPath = req;
|
||||
if (!path) return;
|
||||
try {
|
||||
const peaks = await api.waveform(path, WAVE_BUCKETS);
|
||||
const peaks = await api.waveform(
|
||||
path,
|
||||
WAVE_BUCKETS,
|
||||
range?.start ?? null,
|
||||
range?.end ?? null,
|
||||
);
|
||||
// Ignore if the track changed while we were computing.
|
||||
if (waveReqPath === path) {
|
||||
if (waveReqPath === req) {
|
||||
player.waveform = peaks;
|
||||
player.waveformCoverage = 1;
|
||||
}
|
||||
} catch {
|
||||
if (waveReqPath === path) player.waveform = [];
|
||||
if (waveReqPath === req) player.waveform = [];
|
||||
} finally {
|
||||
// Either way the request is over; a failed local decode rests as dots
|
||||
// rather than swelling forever at a waveform that will never come.
|
||||
if (waveReqPath === path) player.wavePending = false;
|
||||
if (waveReqPath === req) player.wavePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6314,15 +6548,29 @@ export function nextTrack(auto = false) {
|
||||
playQueueIndex(next);
|
||||
}
|
||||
|
||||
/** Engine time for a TRACK-time value. A set-recording slice's engine
|
||||
* clock is the whole file's, so the slice offset is added exactly here —
|
||||
* every transport call that hands the engine a time (seek, scratch,
|
||||
* scratch release) must convert through this door. */
|
||||
export function engineTime(secs) {
|
||||
const seg = player.queue[player.queueIndex]?.segment;
|
||||
return seg ? seg.start + Math.max(0, secs) : secs;
|
||||
}
|
||||
|
||||
/** Seek in TRACK time; see `engineTime`. */
|
||||
export function seekPlayer(secs) {
|
||||
api.playerControl("seek", engineTime(secs));
|
||||
}
|
||||
|
||||
export function prevTrack() {
|
||||
// Restart the current track if we're more than 3s in.
|
||||
if (player.position > 3) {
|
||||
api.playerControl("seek", 0);
|
||||
seekPlayer(0);
|
||||
return;
|
||||
}
|
||||
const prev = player.queueIndex - 1;
|
||||
if (prev >= 0) playQueueIndex(prev);
|
||||
else api.playerControl("seek", 0);
|
||||
else seekPlayer(0);
|
||||
}
|
||||
|
||||
export function toggleShuffle() {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js";
|
||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
|
||||
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
|
||||
const album = $derived(page?.album ?? null);
|
||||
@@ -132,18 +133,23 @@
|
||||
* library keeps albums, not songs, so the owned tracklist has to be read
|
||||
* from the matching local album rather than filtered out of a song list. */
|
||||
let libraryTicks = $state(new Set());
|
||||
/** The same rows with their paths kept, so a playlist add can point at the
|
||||
* owned file instead of a stream placeholder. */
|
||||
let libraryRows = $state([]);
|
||||
let ticksFor = null;
|
||||
$effect(() => {
|
||||
const id = localAlbum?.id ?? null;
|
||||
if (id === ticksFor) return;
|
||||
ticksFor = id;
|
||||
libraryTicks = new Set();
|
||||
libraryRows = [];
|
||||
if (id == null) return;
|
||||
let stale = false;
|
||||
api.albumTracks(id)
|
||||
.then((rows) => {
|
||||
if (!stale && ticksFor === id) {
|
||||
libraryTicks = new Set(rows.map((r) => norm(r.title || "")));
|
||||
libraryRows = rows;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -152,6 +158,40 @@
|
||||
};
|
||||
});
|
||||
|
||||
// ---- add to playlist ----------------------------------------------------
|
||||
/** Local files for this record by normalized title — a local page carries
|
||||
* them in `page.libraryTracks`; an owned catalog page gets them from the
|
||||
* ticks fetch above. */
|
||||
const localByTitle = $derived.by(() => {
|
||||
const m = new Map();
|
||||
for (const lt of page?.libraryTracks ?? []) {
|
||||
const k = norm(lt.title || "");
|
||||
if (k && !m.has(k)) m.set(k, lt);
|
||||
}
|
||||
for (const lt of libraryRows) {
|
||||
const k = norm(lt.title || "");
|
||||
if (k && !m.has(k)) m.set(k, lt);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
/** A playlist row for a track on this record. Owned tracks go in as the
|
||||
* file on disk; the rest are placeholders that stream on click. */
|
||||
function playlistRow(t) {
|
||||
const local = localByTitle.get(norm(t.title || ""));
|
||||
return {
|
||||
title: t.title,
|
||||
artist: album?.artist ?? null,
|
||||
album: album?.title ?? null,
|
||||
duration_secs: t.duration_secs ?? local?.duration_secs ?? null,
|
||||
path: local?.path ?? null,
|
||||
art_path: local ? (page?.artPath ?? localAlbum?.art_path ?? null) : null,
|
||||
// Stream anchors, mirroring what playTrack hands the resolver. A
|
||||
// local record's cover is a file path, not a URL — omit it there.
|
||||
trackId: t.id ?? null,
|
||||
artUrl: isLocal ? null : (t.cover ?? album?.cover ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
// An upcoming album that still has unreleased songs can't be streamed or
|
||||
// saved as a whole — only its already-released singles play (per track).
|
||||
const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased));
|
||||
@@ -804,6 +844,12 @@
|
||||
<span class="badge" aria-hidden="true">{previewable}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={17}
|
||||
title="Add this record to a playlist"
|
||||
tracks={() => tracks.filter((t) => !t.unreleased).map(playlistRow)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -900,6 +946,14 @@
|
||||
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
|
||||
{/if}
|
||||
<span class="rowbtns">
|
||||
{#if !locked}
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={13}
|
||||
align="right"
|
||||
tracks={() => [playlistRow(t)]}
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class="mini-btn play"
|
||||
disabled={locked}
|
||||
@@ -1364,6 +1418,20 @@
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
cursor: default;
|
||||
}
|
||||
/* The add-to-playlist trigger wears the iconbtn look — the component
|
||||
itself stays unstyled chrome (the Library rail set the precedent). */
|
||||
.actions :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.actions :global(.plm-trigger:hover) {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
/* Count for a record only some of whose tracks have a clip — the same
|
||||
partial-availability report the artist page makes. */
|
||||
.badge {
|
||||
@@ -1745,10 +1813,14 @@
|
||||
}
|
||||
.trow:hover .tdur,
|
||||
.trow:focus-within .tdur {
|
||||
/* pointer-events too: the faded duration is a stacking context and
|
||||
must never eat clicks meant for the buttons over it. */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.trow.locked:hover .tdur {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tag-unreleased {
|
||||
@@ -1799,6 +1871,40 @@
|
||||
.rowbtns .play {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
/* Three buttons now reach past the duration column into the listeners
|
||||
bar on counted records, so the cluster carries its own fade-in shim —
|
||||
on the hover highlight it reads as the buttons simply taking over. */
|
||||
.rowbtns {
|
||||
padding-left: 14px;
|
||||
background: linear-gradient(to right, transparent, var(--bg-highlight) 14px);
|
||||
}
|
||||
.trow:focus-within {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.rowbtns :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rowbtns :global(.plm-trigger:hover) {
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
/* While its menu is open the pointer is off in the portaled dropdown, so
|
||||
hover alone would drop the cluster mid-interaction. */
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .rowbtns {
|
||||
display: flex;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
||||
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
|
||||
|
||||
@@ -5071,6 +5072,19 @@
|
||||
<span class="track-album">{track.album}</span>
|
||||
<span class="lis" class:unknown={track.listeners == null} title={track.listeners == null ? "No Last.fm listener data for this track" : `${track.listeners.toLocaleString()} Last.fm listeners`}>{fmtListeners(track.listeners)}</span>
|
||||
<span class="tdur">{fmtDuration(track.duration_secs)}</span>
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={13}
|
||||
align="right"
|
||||
tracks={() => [{
|
||||
title: track.title,
|
||||
artist: page.artist.name,
|
||||
album: track.album ?? null,
|
||||
duration_secs: track.duration_secs ?? null,
|
||||
trackId: track.id ?? null,
|
||||
artUrl: track.cover ?? null,
|
||||
}]}
|
||||
/>
|
||||
<button
|
||||
class="mini-get"
|
||||
title="Save to library"
|
||||
@@ -5425,6 +5439,19 @@
|
||||
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
|
||||
{/if}
|
||||
{#if !locked}
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={13}
|
||||
align="right"
|
||||
tracks={() => [{
|
||||
title: t.title,
|
||||
artist: page.artist.name,
|
||||
album: openAlbum?.title ?? null,
|
||||
duration_secs: t.duration_secs ?? null,
|
||||
trackId: t.id ?? null,
|
||||
artUrl: t.cover ?? openAlbum?.cover ?? null,
|
||||
}]}
|
||||
/>
|
||||
<button
|
||||
class="mini-btn play"
|
||||
title="Play"
|
||||
@@ -7633,6 +7660,11 @@
|
||||
}
|
||||
|
||||
.popular-row {
|
||||
/* The anchor the absolute row actions were always written against —
|
||||
without it the save button resolved against the page and its
|
||||
reveal rules never existed, so it was dead UI. The 24px last
|
||||
column is the space the overlaid actions land on. */
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 62px 40px 24px;
|
||||
align-items: center;
|
||||
@@ -7647,6 +7679,56 @@
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
/* Hover slides the actions over the right end and the duration gets out
|
||||
of the way — the album page's treatment. The listener count stays: the
|
||||
list is ordered by it. */
|
||||
.popular-row :global(.plm) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 36px;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.popular-row :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.popular-row :global(.plm-trigger:hover) {
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.popular-row:hover .mini-get,
|
||||
.popular-row:focus-within .mini-get,
|
||||
.popular-row:hover :global(.plm),
|
||||
.popular-row:focus-within :global(.plm) {
|
||||
opacity: 1;
|
||||
}
|
||||
.popular-row:hover .tdur,
|
||||
.popular-row:focus-within .tdur {
|
||||
/* pointer-events too: the faded duration is a stacking context and
|
||||
must never eat clicks meant for the buttons over it. */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* While its menu is open the pointer is off in the portaled dropdown, so
|
||||
hover alone would drop the cluster mid-interaction. */
|
||||
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
|
||||
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
|
||||
opacity: 1;
|
||||
}
|
||||
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.popular-row.now .rank,
|
||||
.popular-row.now .track-name {
|
||||
color: var(--accent-hover);
|
||||
@@ -8984,6 +9066,45 @@
|
||||
.trow:hover .tdur,
|
||||
.trow:focus-within .tdur {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Add-to-playlist joins the expander row's overlay pair, one slot left of
|
||||
Play; its menu is portaled, so hover alone would drop the cluster while
|
||||
the menu is open — the aria-expanded rules hold it. */
|
||||
.trow :global(.plm) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 64px;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.trow :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trow :global(.plm-trigger:hover) {
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.trow:hover :global(.plm),
|
||||
.trow:focus-within :global(.plm),
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-btn,
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
|
||||
opacity: 1;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.trow.locked {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
|
||||
const TABS = ["All", "Songs", "Albums", "Artists", "In your library"];
|
||||
|
||||
@@ -1284,6 +1285,19 @@
|
||||
<span>{fmtCount(listenersOf(t))}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={14}
|
||||
align="right"
|
||||
tracks={() => [{
|
||||
title: t.title,
|
||||
artist: t.artist ?? null,
|
||||
album: t.album ?? null,
|
||||
duration_secs: t.duration_secs ?? null,
|
||||
trackId: t.id ?? null,
|
||||
artUrl: t.cover ?? null,
|
||||
}]}
|
||||
/>
|
||||
<button
|
||||
class="sv"
|
||||
title="Save to library"
|
||||
@@ -1928,7 +1942,7 @@
|
||||
/* ---- song rows ------------------------------------------------------ */
|
||||
.srow {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 36px 1fr auto auto;
|
||||
grid-template-columns: 20px 36px 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 7px 9px;
|
||||
@@ -1936,7 +1950,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.srow.has-count {
|
||||
grid-template-columns: 20px 36px 1fr auto auto auto;
|
||||
grid-template-columns: 20px 36px 1fr auto auto auto auto;
|
||||
}
|
||||
.srow:hover {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
@@ -2023,6 +2037,28 @@
|
||||
.sv:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
/* Add-to-playlist rides one cell left of Save, same reveal; the
|
||||
aria-expanded rules hold the pair while its portaled menu is open. */
|
||||
.srow :global(.plm) {
|
||||
opacity: 0;
|
||||
}
|
||||
.srow :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.srow :global(.plm-trigger:hover) {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.srow:hover :global(.plm),
|
||||
.srow:focus-within :global(.plm),
|
||||
.srow:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
|
||||
.srow:has(:global(.plm-trigger[aria-expanded="true"])) .sv {
|
||||
opacity: 1;
|
||||
}
|
||||
.srow:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
/* Listener weight: the bar's LENGTH is the count, curved by
|
||||
src/lib/listenerBars.js so one runaway hit cannot squash the rest of the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { downloads, network, playLocalFile } from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
|
||||
const uploads = $derived(
|
||||
Object.values(network.uploads)
|
||||
@@ -149,6 +150,15 @@
|
||||
title="Show in folder"
|
||||
onclick={() => api.showInFolder(d.local_path)}
|
||||
><Icon name="folder-open" size={16} /></button>
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={15}
|
||||
align="right"
|
||||
tracks={() => [{
|
||||
title: d.name.replace(/\.[^.]+$/, ""),
|
||||
path: d.local_path,
|
||||
}]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -305,4 +315,14 @@
|
||||
.reveal:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0.8;
|
||||
color: inherit;
|
||||
}
|
||||
.row :global(.plm-trigger:hover) {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,791 @@
|
||||
<script>
|
||||
// The lyrics PAGE — variant C "Immersion" of research/lyrics-redesign,
|
||||
// implemented to the user's dialed recipe verbatim (2026-08-18):
|
||||
// 38px/1028px centred column, gap 0.76em, idle 42%, active ×1.20,
|
||||
// hero dim 57% + blur 3px, accent wash 22%, duotone 56%, focus falloff
|
||||
// 0.3px·d² (capped), sung lines dimmed harder, accent karaoke fill,
|
||||
// scroll-to-seek, word timing when the source carries it. Hero drift and
|
||||
// auto-hide chrome were tried in the mock and left off.
|
||||
//
|
||||
// A real page in the shell — glass bar above, player bar below, Esc goes
|
||||
// back. It replaced the fullscreen overlay room.
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
player,
|
||||
ui,
|
||||
theme,
|
||||
catalog,
|
||||
getArt,
|
||||
getArtMaster,
|
||||
navBack,
|
||||
resolveArtistSeedByName,
|
||||
getCatalogArtistPage,
|
||||
getCatalogArtistHeroSeed,
|
||||
} from "../lib/state.svelte.js";
|
||||
import {
|
||||
lyricsStore,
|
||||
lyricIdentity,
|
||||
ensureLyrics,
|
||||
noteTrackChange,
|
||||
activeLineIndex,
|
||||
wordClockMs,
|
||||
wordfill,
|
||||
totalOffsetMs,
|
||||
nudgeOffset,
|
||||
setAutoSync,
|
||||
} from "../lib/lyrics.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
|
||||
// ---- the playing identity -------------------------------------------
|
||||
const currentItem = $derived(player.queue?.[player.queueIndex] ?? null);
|
||||
const title = $derived(player.track?.title ?? "");
|
||||
const artistName = $derived(
|
||||
(player.track?.artist ?? "").trim()
|
||||
|| String(player.track?.sub ?? "").split(" — ")[0].trim(),
|
||||
);
|
||||
const albumName = $derived(currentItem?.album ?? null);
|
||||
|
||||
// Chip artwork: a local file's embedded art, else the stream's cover URL.
|
||||
let chipArt = $state(null);
|
||||
$effect(() => {
|
||||
const item = currentItem;
|
||||
chipArt = null;
|
||||
if (!item) return;
|
||||
if (item.art) {
|
||||
let stale = false;
|
||||
getArt(item.art, 256).then((u) => { if (!stale) chipArt = u; }).catch(() => {});
|
||||
return () => { stale = true; };
|
||||
}
|
||||
chipArt = item.artUrl ?? null;
|
||||
});
|
||||
|
||||
/** A Deezer cover URL re-asked at hero size — a display upgrade of the
|
||||
* SAME asset, not a different lookup. */
|
||||
function coverAtHeroSize(url) {
|
||||
return typeof url === "string"
|
||||
? url.replace(/\/(\d+)x\1(-[0-9a-f-]+\.jpg)$/i, "/1000x1000$2")
|
||||
: url;
|
||||
}
|
||||
|
||||
// ---- the backdrop ladder --------------------------------------------
|
||||
// Artist hero first — the staged artist page's fully-resolved set when
|
||||
// the user has it open in this session, else the cached payload's
|
||||
// banners/seed (the raw payload keeps the hero SEED; the resolved set
|
||||
// only stages on the artist page itself). The record's cover stands in
|
||||
// until a hero lands, and stays for artists without one — at MASTER
|
||||
// resolution for local files, never the 256px chip thumb (a thumbnail
|
||||
// stretched over the whole stage is what "low quality" looks like).
|
||||
/** Every http hero candidate a page payload carries, resolved set first. */
|
||||
function bannersOf(p) {
|
||||
const list = p?.banners?.length
|
||||
? p.banners
|
||||
: p?.banner
|
||||
? [p.banner]
|
||||
: p?.hero_seed_banner
|
||||
? [p.hero_seed_banner]
|
||||
: [];
|
||||
return list.filter((b) => typeof b === "string" && b.startsWith("http"));
|
||||
}
|
||||
let heroUrl = $state(null);
|
||||
let heroGen = 0;
|
||||
$effect(() => {
|
||||
const name = artistName;
|
||||
const gen = ++heroGen;
|
||||
heroUrl = null;
|
||||
if (!name) return;
|
||||
// The artist page already staged this artist? Its resolved hero set
|
||||
// is the best source and costs nothing.
|
||||
const staged = catalog.artistPage;
|
||||
if (
|
||||
staged?.artist?.name
|
||||
&& staged.artist.name.trim().toLowerCase() === name.trim().toLowerCase()
|
||||
) {
|
||||
const b = bannersOf(staged);
|
||||
if (b.length) {
|
||||
heroUrl = b[0];
|
||||
return;
|
||||
}
|
||||
}
|
||||
untrack(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const seed = await resolveArtistSeedByName(name);
|
||||
if (heroGen !== gen || seed?.id == null) return;
|
||||
// The full payload's banners cover custom heroes; the
|
||||
// HERO SEED is the standard case — the same image the
|
||||
// artist page paints first.
|
||||
const page = await getCatalogArtistPage(seed.id).catch(() => null);
|
||||
if (heroGen !== gen) return;
|
||||
const b = bannersOf(page);
|
||||
if (b.length) {
|
||||
heroUrl = b[0];
|
||||
return;
|
||||
}
|
||||
const hs = await getCatalogArtistHeroSeed(seed.id);
|
||||
if (heroGen !== gen) return;
|
||||
const url =
|
||||
typeof hs === "string"
|
||||
? hs
|
||||
: hs?.banner ?? hs?.url ?? hs?.src ?? null;
|
||||
if (typeof url === "string" && url.startsWith("http")) heroUrl = url;
|
||||
} catch {
|
||||
// no banner — the cover backdrop stands
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
// The stage's cover: the real pixels. Local files ask for the MASTER
|
||||
// (the deliberate act getArt's doc demands of full-bleed surfaces);
|
||||
// stream covers get re-asked at hero size.
|
||||
let coverBig = $state(null);
|
||||
$effect(() => {
|
||||
const item = currentItem;
|
||||
coverBig = null;
|
||||
if (!item) return;
|
||||
if (item.art) {
|
||||
let stale = false;
|
||||
getArtMaster(item.art).then((u) => { if (!stale) coverBig = u; }).catch(() => {});
|
||||
return () => { stale = true; };
|
||||
}
|
||||
coverBig = coverAtHeroSize(item.artUrl ?? null);
|
||||
});
|
||||
|
||||
const backdrop = $derived(heroUrl ?? coverBig ?? chipArt);
|
||||
|
||||
// ---- lyrics ----------------------------------------------------------
|
||||
let shownKey = null;
|
||||
$effect(() => {
|
||||
const identity = lyricIdentity();
|
||||
const key = identity
|
||||
? `${identity.artist} ${identity.title} ${identity.durationSecs ?? "x"}`
|
||||
: "";
|
||||
if (key !== shownKey) shownKey = key;
|
||||
// untrack: ensureLyrics reads and writes store fields synchronously —
|
||||
// tracked, a failed fetch retriggers forever (the pane documents it).
|
||||
untrack(() => {
|
||||
noteTrackChange();
|
||||
void ensureLyrics();
|
||||
});
|
||||
});
|
||||
|
||||
const lines = $derived(lyricsStore.status === "synced" ? lyricsStore.lines : []);
|
||||
const active = $derived(
|
||||
lyricsStore.status === "synced" ? activeLineIndex(player.position ?? 0) : -1,
|
||||
);
|
||||
|
||||
// ---- follow + scrub --------------------------------------------------
|
||||
let feed = $state(null);
|
||||
let holdUntil = 0;
|
||||
let scrubIdx = $state(null);
|
||||
let scrubbing = $state(false);
|
||||
let chipPos = $state({ top: 0, left: 0 });
|
||||
let settleTimer = 0;
|
||||
let programmaticUntil = 0;
|
||||
|
||||
// The lens centres on the scrubbed line while the wheel is live,
|
||||
// otherwise on the playing one.
|
||||
const focusIdx = $derived(scrubIdx ?? active);
|
||||
|
||||
function centreOn(index, smooth = true) {
|
||||
if (!feed || index < 0) return;
|
||||
const el = feed.querySelector(`[data-line="${index}"]`);
|
||||
if (!el) return;
|
||||
const fr = feed.getBoundingClientRect();
|
||||
const lr = el.getBoundingClientRect();
|
||||
const top = feed.scrollTop + (lr.top + lr.height / 2) - (fr.top + fr.height / 2);
|
||||
programmaticUntil = performance.now() + 800;
|
||||
feed.scrollTo({
|
||||
top: Math.max(0, top),
|
||||
behavior: smooth && ui.windowActive ? "smooth" : "auto",
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const index = active;
|
||||
if (lyricsStore.status !== "synced") return;
|
||||
if (performance.now() < holdUntil) return;
|
||||
centreOn(index);
|
||||
});
|
||||
|
||||
function fmtStamp(ms) {
|
||||
const secs = Math.max(0, Math.round((ms + totalOffsetMs()) / 1000));
|
||||
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function onUserScroll() {
|
||||
if (performance.now() < programmaticUntil) return;
|
||||
if (!feed || lyricsStore.status !== "synced") return;
|
||||
holdUntil = performance.now() + 3500;
|
||||
const fr = feed.getBoundingClientRect();
|
||||
const y = fr.top + fr.height / 2;
|
||||
let best = -1;
|
||||
let bd = Infinity;
|
||||
feed.querySelectorAll("[data-line]").forEach((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const d = Math.abs(r.top + r.height / 2 - y);
|
||||
if (d < bd) { bd = d; best = Number(el.dataset.line); }
|
||||
});
|
||||
if (best < 0) return;
|
||||
scrubIdx = best;
|
||||
scrubbing = true;
|
||||
chipPos = { top: Math.round(y - 16), left: Math.max(10, Math.round(fr.left + fr.width / 2 - 640)) };
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(settleScrub, 340);
|
||||
}
|
||||
|
||||
function settleScrub() {
|
||||
const idx = scrubIdx;
|
||||
scrubbing = false;
|
||||
scrubIdx = null;
|
||||
if (idx == null) return;
|
||||
const line = lines[idx];
|
||||
if (!line) return;
|
||||
// The jump: scrolling through the words IS seeking the song.
|
||||
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
|
||||
api.playerControl("seek", secs).catch(() => {});
|
||||
holdUntil = 0;
|
||||
}
|
||||
|
||||
function seekTo(line) {
|
||||
clearTimeout(settleTimer);
|
||||
scrubbing = false;
|
||||
scrubIdx = null;
|
||||
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
|
||||
api.playerControl("seek", secs).catch(() => {});
|
||||
holdUntil = 0;
|
||||
}
|
||||
|
||||
/** Focus falloff, the recipe's quadratic ramp: min(d²/4, 8) × 0.3px. */
|
||||
function dofBlur(i) {
|
||||
const d = Math.abs(i - focusIdx);
|
||||
return Math.min((d * d) / 4, 8);
|
||||
}
|
||||
|
||||
const SOURCE_LABEL = {
|
||||
sidecar: "from a lyrics file",
|
||||
embedded: "from the audio file",
|
||||
lrclib: "via LRCLIB",
|
||||
amll: "via AMLL DB · word-synced",
|
||||
picked: "picked on LRCLIB",
|
||||
user: "pasted by you",
|
||||
};
|
||||
|
||||
function onkeydown(e) {
|
||||
if (e.key === "Escape") void navBack();
|
||||
}
|
||||
|
||||
// The room follows the music out: nothing playing, nothing to show.
|
||||
$effect(() => {
|
||||
if (ui.view === "lyrics" && !player.track) void navBack();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={ui.view === "lyrics" ? onkeydown : undefined} />
|
||||
|
||||
<div class="lyrpage" style="--wash-accent: {theme.accent}">
|
||||
<div class="full" aria-hidden="true">
|
||||
{#if backdrop}
|
||||
{#key backdrop}
|
||||
<img src={backdrop} alt="" draggable="false" />
|
||||
{/key}
|
||||
<span class="tint"></span>
|
||||
{/if}
|
||||
<span class="grade"></span>
|
||||
</div>
|
||||
|
||||
{#if title}
|
||||
<div class="chip">
|
||||
{#if chipArt}
|
||||
<img src={chipArt} alt="" draggable="false" />
|
||||
{:else}
|
||||
<span class="chip-fallback"><Icon name="music" size={18} /></span>
|
||||
{/if}
|
||||
<div>
|
||||
<div class="t">{title}</div>
|
||||
<div class="a">{artistName}{albumName ? ` — ${albumName}` : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lyricsStore.status === "synced"}
|
||||
<div class="tools">
|
||||
<span class="src">{SOURCE_LABEL[lyricsStore.source] ?? ""}</span>
|
||||
{#if lyricsStore.autoOffsetMs !== 0}
|
||||
<button
|
||||
class="autochip"
|
||||
title="Timing auto-corrected from this file's extra lead-in — click to turn off"
|
||||
onclick={() => setAutoSync(false)}
|
||||
>
|
||||
auto {lyricsStore.autoOffsetMs > 0 ? "+" : ""}{(lyricsStore.autoOffsetMs / 1000).toFixed(1)}s
|
||||
</button>
|
||||
{/if}
|
||||
<span class="nudge" title="Shift lyric timing">
|
||||
<button class="tool" title="Lyrics earlier" onclick={() => nudgeOffset(-500)}>−</button>
|
||||
<span class="off" class:on={lyricsStore.offsetMs !== 0}>
|
||||
{lyricsStore.offsetMs === 0
|
||||
? "sync"
|
||||
: `${lyricsStore.offsetMs > 0 ? "+" : ""}${(lyricsStore.offsetMs / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
<button class="tool" title="Lyrics later" onclick={() => nudgeOffset(500)}>+</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="feed"
|
||||
bind:this={feed}
|
||||
onwheel={onUserScroll}
|
||||
ontouchmove={onUserScroll}
|
||||
onscroll={onUserScroll}
|
||||
>
|
||||
<div class="pad"></div>
|
||||
{#each lines as line, index (index)}
|
||||
{#if !line.text}
|
||||
<button
|
||||
class="line gapdots"
|
||||
class:on={index === active}
|
||||
class:past={index < active}
|
||||
data-line={index}
|
||||
style="--dblur: {dofBlur(index)}"
|
||||
onclick={() => seekTo(line)}
|
||||
title="Play from here"
|
||||
><i></i><i></i><i></i></button>
|
||||
{:else}
|
||||
<button
|
||||
class="line"
|
||||
class:on={index === active}
|
||||
class:past={index < active}
|
||||
class:scrub={index === scrubIdx}
|
||||
data-line={index}
|
||||
style="--dblur: {dofBlur(index)}"
|
||||
onclick={() => seekTo(line)}
|
||||
title="Play from here"
|
||||
>
|
||||
{#if index === active && lyricsStore.wordFill && line.words?.length}
|
||||
<!-- Karaoke word fill over REAL word times (the
|
||||
wordfill mock's dialed variant A): each word
|
||||
wipes across its OWN sung span, silence
|
||||
holds — never invented (the Noctis rule). -->
|
||||
<span
|
||||
class="wline"
|
||||
use:wordfill={{
|
||||
line,
|
||||
ms: wordClockMs(line, player.position ?? 0),
|
||||
endBound: lines[index + 1]?.time_ms
|
||||
?? line.words[line.words.length - 1].time_ms + 2500,
|
||||
}}
|
||||
>
|
||||
{#each line.words as word, wi (wi)}
|
||||
<span class="fw">{word.text}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Line mode: the active line is a SOLID accent
|
||||
fill (.line.on) — the interpolated sweep is
|
||||
gone (Kyle 2026-08-19: mid-word splits read
|
||||
as glitches; only REAL word times may move
|
||||
through letters, and those live behind the
|
||||
experimental toggle above). -->
|
||||
{line.text}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="pad"></div>
|
||||
</div>
|
||||
{#if scrubbing && scrubIdx != null && lines[scrubIdx]}
|
||||
<div class="scrubchip" style="top: {chipPos.top}px; left: {chipPos.left}px">
|
||||
{fmtStamp(lines[scrubIdx].time_ms)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if lyricsStore.status === "plain"}
|
||||
<div class="feed plain">
|
||||
<div class="pad"></div>
|
||||
{#each (lyricsStore.plain ?? "").split("\n") as pline, i (i)}
|
||||
<p class="line stat">{pline.trim() ? pline : "· · ·"}</p>
|
||||
{/each}
|
||||
<div class="pad"></div>
|
||||
</div>
|
||||
<div class="statechip">not synced — the rail's lyrics pane can time them</div>
|
||||
{:else if lyricsStore.status === "loading" || lyricsStore.status === "idle"}
|
||||
<div class="statebox">
|
||||
<span class="ring"></span>
|
||||
</div>
|
||||
{:else if lyricsStore.status === "instrumental"}
|
||||
<div class="statebox">
|
||||
<Icon name="music" size={30} />
|
||||
<p>Instrumental — sit back.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="statebox">
|
||||
<Icon name="music" size={30} />
|
||||
<p>No lyrics found for this one.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* The stage fills the pane, edge to edge, and never scrolls itself —
|
||||
the feed inside it does. Follows the Library/discography exception:
|
||||
the backdrop must begin at the very pane top, behind the glass.
|
||||
Height arithmetic: 100% resolves against the pane's CONTENT box —
|
||||
both --pane-pad bands already subtracted — while the glass bar
|
||||
cancels the top one, so a bare 100% ends 2×pad short and the hero
|
||||
stops in a black band above the player bar (Kyle, 2026-08-19).
|
||||
Reclaim both pads and bleed the bottom one away. */
|
||||
.lyrpage {
|
||||
position: relative;
|
||||
margin: 0 -28px calc(-1 * var(--pane-pad, 24px));
|
||||
height: calc(100% + 2 * var(--pane-pad, 24px));
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
/* The type scale below is container-relative: the page is the
|
||||
container (its height is an explicit calc, so `size` is legal). */
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
/* ---- backdrop: hero (or cover) in the record's own duotone ---- */
|
||||
.full {
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
z-index: 0;
|
||||
}
|
||||
.full img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 24%;
|
||||
/* recipe: hero-dim 57%, hero-blur 3px, duotone base */
|
||||
filter: grayscale(1) brightness(0.43) blur(3px) contrast(1.05);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.full .tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* recipe: duotone 56%, tone pulled toward neutral so it reads like a
|
||||
sleeve, not paint */
|
||||
background: color-mix(in srgb, var(--wash-accent) 80%, #77706e);
|
||||
mix-blend-mode: color;
|
||||
opacity: 0.56;
|
||||
}
|
||||
.full .grade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(120% 90% at 50% 108%, rgba(10, 10, 10, 0.9), rgba(10, 10, 10, 0.42) 55%, rgba(10, 10, 10, 0.28)),
|
||||
radial-gradient(80% 60% at 50% 0%, color-mix(in srgb, var(--wash-accent) 22%, transparent), transparent 62%);
|
||||
}
|
||||
|
||||
/* ---- identity chip, top centre under the glass ---- */
|
||||
.chip {
|
||||
position: absolute;
|
||||
top: 72px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 3;
|
||||
background: rgba(12, 12, 12, 0.5);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 9px 16px 9px 9px;
|
||||
max-width: min(60%, 560px);
|
||||
}
|
||||
.chip img,
|
||||
.chip-fallback {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #232323;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.chip .t {
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip .a {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- source + nudge, top right under the glass ---- */
|
||||
.tools {
|
||||
position: absolute;
|
||||
top: calc(var(--bar-h, 46px) + 12px);
|
||||
right: 24px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.src {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.autochip {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-hover);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
.nudge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.nudge .tool {
|
||||
color: var(--text-secondary);
|
||||
padding: 0 3px;
|
||||
}
|
||||
.nudge .tool:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nudge .off.on {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* ---- the feed ---- */
|
||||
.feed {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
-webkit-mask-image: linear-gradient(180deg, transparent 0, #000 12%, #000 88%, transparent 100%);
|
||||
mask-image: linear-gradient(180deg, transparent 0, #000 12%, #000 88%, transparent 100%);
|
||||
}
|
||||
.feed::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.feed .pad {
|
||||
flex: none;
|
||||
height: 52vh;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: relative;
|
||||
flex: none;
|
||||
/* recipe: 38px / 1028px / 0.76em gap, centred — the 38 now the CAP
|
||||
of a container-relative scale, so a small window shrinks the type
|
||||
with it (Kyle, 2026-08-19) and a full-size pane is pixel-identical
|
||||
to the dialed recipe. Coefficients: 3.5cqi ≈ 38px at the pane
|
||||
width where the 1028px feed first compresses (~1086px incl the
|
||||
reclaimed side pads); 5.3cqb ≈ 38px at the ~717px pane height of
|
||||
the dialed layout; the SMALLER axis governs, floored for tiny
|
||||
windows. Everything inside the line is em-based and follows. */
|
||||
font-size: clamp(20px, min(3.5cqi, 5.3cqb), 38px);
|
||||
line-height: 1.28;
|
||||
font-weight: 700;
|
||||
max-width: min(1028px, 86%);
|
||||
margin: 0.38em 0;
|
||||
text-align: center;
|
||||
/* recipe: idle 42% */
|
||||
color: color-mix(in srgb, var(--text-primary) 42%, transparent);
|
||||
transform-origin: center;
|
||||
transition:
|
||||
color 260ms ease,
|
||||
transform 320ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 260ms ease,
|
||||
filter 260ms ease;
|
||||
/* recipe: focus falloff 0.3px × d²/4, capped */
|
||||
filter: blur(calc(var(--dblur, 1) * 0.3px));
|
||||
cursor: pointer;
|
||||
}
|
||||
/* recipe: sung lines dim harder than unsung */
|
||||
.line.past {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.line.on {
|
||||
/* recipe: active ×1.20; SOLID accent letters — the whole line is
|
||||
the highlight (word mode's children clip their own fills and
|
||||
ignore this color). */
|
||||
transform: scale(1.2);
|
||||
color: var(--accent-hover);
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.line.scrub {
|
||||
color: var(--text-primary);
|
||||
filter: none;
|
||||
}
|
||||
.line:not(.on):hover {
|
||||
color: color-mix(in srgb, var(--text-primary) 80%, transparent);
|
||||
filter: none;
|
||||
}
|
||||
.line:not(.on):hover::before {
|
||||
content: "▶";
|
||||
position: absolute;
|
||||
left: -30px;
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
font-size: 0.38em;
|
||||
color: var(--accent-hover);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* accent karaoke fill: ONE glyph raster — the active line's text is
|
||||
painted by a gradient clipped to the glyphs, sung accent left of the
|
||||
feathered boundary, unsung white right of it. (Two stacked text layers
|
||||
fringe against each other; this cannot.)
|
||||
|
||||
NAMESPACED on pain of death: @property registrations are DOCUMENT
|
||||
GLOBAL — registering bare `--p` as a percentage broke the artist
|
||||
page's scroll variable of the same name (its unitless writes stopped
|
||||
parsing and the whole hero pipeline collapsed). */
|
||||
@property --lyr-p {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
/* karaoke word fill (the wordfill mock's dialed variant A): each word is
|
||||
its OWN gradient-clip raster — accent left of its feathered boundary,
|
||||
unsung white right of it — wiping across the word's real sung span.
|
||||
Feather ±8%, mock-dialed. --lyr-p is per-element (inherits: false). */
|
||||
.wline .fw {
|
||||
--lyr-p: -8%;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--accent-hover) calc(var(--lyr-p) - 8%),
|
||||
var(--text-primary) calc(var(--lyr-p) + 8%)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
.wline .fw + .fw {
|
||||
margin-left: 0.28em;
|
||||
}
|
||||
|
||||
/* instrumental gap: three breathing dots */
|
||||
.line.gapdots {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.28em;
|
||||
}
|
||||
.line.gapdots i {
|
||||
width: 0.32em;
|
||||
height: 0.32em;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--text-primary) 40%, transparent);
|
||||
}
|
||||
.line.gapdots.on i {
|
||||
animation: dotpulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.line.gapdots.on i:nth-child(2) {
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
.line.gapdots.on i:nth-child(3) {
|
||||
animation-delay: 0.44s;
|
||||
}
|
||||
@keyframes dotpulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
background: color-mix(in srgb, var(--text-primary) 45%, transparent);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.55);
|
||||
background: var(--text-primary);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.line.gapdots.on i {
|
||||
animation: none;
|
||||
background: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* scrub time chip, pinned to the focus band */
|
||||
.scrubchip {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: rgba(12, 12, 12, 0.62);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
.scrubchip::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- non-synced states ---- */
|
||||
.feed.plain .line.stat {
|
||||
cursor: default;
|
||||
filter: none;
|
||||
}
|
||||
.statechip {
|
||||
position: absolute;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(12, 12, 12, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
.statebox {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.ring {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid rgba(255, 255, 255, 0.16);
|
||||
border-top-color: var(--accent-hover);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
+596
-272
File diff suppressed because it is too large.
Load diff
+285
-101
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
// The playlist shelf: every playlist as a cover card — custom art or the
|
||||
// composed four-cover mosaic — with pinning, duplication, export, and the
|
||||
// two import doors (a playlist file, a pasted tracklist).
|
||||
// the import doors (playlist file, folder, pasted tracklist, YouTube set).
|
||||
import {
|
||||
playlistStore,
|
||||
refreshPlaylists,
|
||||
@@ -10,6 +10,8 @@
|
||||
playAlbum,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { setlistScan, takeScanDraft } from "../lib/setlistScan.svelte.js";
|
||||
import { fade } from "svelte/transition";
|
||||
import Icon from "./../components/Icon.svelte";
|
||||
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
|
||||
|
||||
@@ -34,6 +36,27 @@
|
||||
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
|
||||
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
|
||||
|
||||
// The stat row's numbers — derived from what the store already carries.
|
||||
const totals = $derived.by(() => {
|
||||
let tracks = 0;
|
||||
let secs = 0;
|
||||
for (const p of playlistStore.playlists) {
|
||||
tracks += p.track_count ?? 0;
|
||||
secs += p.duration_secs ?? 0;
|
||||
}
|
||||
return { tracks, secs };
|
||||
});
|
||||
|
||||
// The ambience wash borrows the first pinned playlist's cover, else the
|
||||
// first cover that has resolved. Covers arrive lazily, so this settles a
|
||||
// beat after the cards do — the keyed fade below makes that a dissolve.
|
||||
const ambUrl = $derived.by(() => {
|
||||
const pick =
|
||||
playlistStore.playlists.find((p) => p.pinned && playlistStore.covers[p.id]) ??
|
||||
playlistStore.playlists.find((p) => playlistStore.covers[p.id]);
|
||||
return pick ? playlistStore.covers[pick.id] : null;
|
||||
});
|
||||
|
||||
async function newPlaylist() {
|
||||
if (creating) return;
|
||||
creating = true;
|
||||
@@ -90,6 +113,23 @@
|
||||
importOpen = true;
|
||||
}
|
||||
|
||||
// A finished background listen: the chip's "review" click lands here —
|
||||
// consume the waiting draft into the ordinary review modal.
|
||||
$effect(() => {
|
||||
if (setlistScan.wantsReview && setlistScan.draft && !importOpen) {
|
||||
importDraft = takeScanDraft();
|
||||
importMode = "file";
|
||||
importOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
function importFromYoutube() {
|
||||
newMenuOpen = false;
|
||||
importDraft = null;
|
||||
importMode = "youtube";
|
||||
importOpen = true;
|
||||
}
|
||||
|
||||
function open(pl) {
|
||||
goToView("playlist", { playlistId: pl.id, label: pl.name });
|
||||
}
|
||||
@@ -102,21 +142,49 @@
|
||||
const gen = ++playCardGen;
|
||||
const rows = await api.playlistItems(pl.id);
|
||||
if (gen !== playCardGen) return;
|
||||
const playable = rows.filter((r) => r.path && !r.missing);
|
||||
// Present local files plus stream rows — streams queue as pending
|
||||
// items that resolve when reached, same as the playlist page's Play.
|
||||
const playable = rows.filter((r) => (r.path && !r.missing) || r.kind === "stream");
|
||||
if (!playable.length) {
|
||||
open(pl); // nothing playable: the page explains why
|
||||
return;
|
||||
}
|
||||
// Set-recording slices: each is bounded by the NEXT stamped row in
|
||||
// the full order (the playlist page derives the same map).
|
||||
const segmentEnds = new Map();
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
if (r.kind !== "segment" || r.start_ms == null) continue;
|
||||
const next = rows.slice(i + 1).find((n) => n.start_ms != null);
|
||||
segmentEnds.set(r.id, next ? next.start_ms : null);
|
||||
}
|
||||
playAlbum(
|
||||
playable.map((r) => ({
|
||||
path: r.path,
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
album: r.album,
|
||||
duration_secs: r.duration_secs,
|
||||
art_path: r.art_path,
|
||||
plItemId: r.id,
|
||||
})),
|
||||
playable.map((r) => {
|
||||
const local = r.path && !r.missing;
|
||||
const tid = Number(r.track_id);
|
||||
const endMs = segmentEnds.get(r.id);
|
||||
const segment =
|
||||
r.kind === "segment" && local && r.start_ms != null
|
||||
? { start: r.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
|
||||
: null;
|
||||
return {
|
||||
path: local ? r.path : null,
|
||||
needsResolve: !local,
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
album: r.album,
|
||||
duration_secs:
|
||||
r.duration_secs ??
|
||||
(segment && segment.end != null
|
||||
? Math.round(segment.end - segment.start)
|
||||
: null),
|
||||
art_path: local ? r.art_path : null,
|
||||
artUrl: local ? null : (r.art_url ?? null),
|
||||
trackId: Number.isFinite(tid) ? tid : null,
|
||||
plItemId: r.id,
|
||||
segment,
|
||||
};
|
||||
}),
|
||||
pl.name,
|
||||
0,
|
||||
);
|
||||
@@ -186,53 +254,85 @@
|
||||
<svelte:window onclick={pageClick} />
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<div class="head-text">
|
||||
{#if ambUrl}
|
||||
<!-- The Library's ambience recipe: a blurred cover pinned behind the
|
||||
glass, faded out before the cards. Keyed so a cover change
|
||||
CROSSFADES — background-image is not an animatable property, so
|
||||
the outgoing wash survives as its own element while the incoming
|
||||
one fades up over it. -->
|
||||
{#key ambUrl}
|
||||
<div class="ambience" style="background-image:url({ambUrl})" transition:fade={{ duration: 600 }}></div>
|
||||
{/key}
|
||||
<div class="ambience-glow"></div>
|
||||
{/if}
|
||||
|
||||
<!-- The Library's own header shape: h1 + tools on one line, the stat row
|
||||
under them. An empty shelf keeps the same header — the Import door
|
||||
matters most at the moment there is nothing here. -->
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<h1>Playlists</h1>
|
||||
{#if playlistStore.playlists.length}
|
||||
<p class="sub">
|
||||
{playlistStore.playlists.length}
|
||||
{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
class="btn ghost"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
menuFor = null;
|
||||
newMenuOpen = !newMenuOpen;
|
||||
}}
|
||||
>
|
||||
<Icon name="download" size={15} /> Import
|
||||
<Icon name="chevron-down" size={13} />
|
||||
<div class="tools">
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
class="tool"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
menuFor = null;
|
||||
newMenuOpen = !newMenuOpen;
|
||||
}}
|
||||
>
|
||||
<Icon name="download" size={15} /> Import
|
||||
<Icon name="chevron-down" size={13} />
|
||||
</button>
|
||||
{#if newMenuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button class="mi" onclick={importFromFile} disabled={probing}>
|
||||
<Icon name="file-text" size={15} />
|
||||
Playlist file…
|
||||
<span class="hint">M3U · XSPF · PLS · CSV · JSON</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromFolder} disabled={probing}>
|
||||
<Icon name="folder-open" size={15} />
|
||||
Folder of audio files…
|
||||
<span class="hint">tag or natural order</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromText}>
|
||||
<Icon name="pencil" size={15} />
|
||||
Paste a tracklist…
|
||||
<span class="hint">plain lines or timestamped setlist</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromYoutube}>
|
||||
<Icon name="link" size={15} />
|
||||
YouTube DJ set…
|
||||
<span class="hint">tracks from chapters, description or comments</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="tool primary" onclick={newPlaylist} disabled={creating}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</button>
|
||||
{#if newMenuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button class="mi" onclick={importFromFile} disabled={probing}>
|
||||
<Icon name="file-text" size={15} />
|
||||
Playlist file…
|
||||
<span class="hint">M3U · XSPF · PLS · CSV · JSON</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromFolder} disabled={probing}>
|
||||
<Icon name="folder-open" size={15} />
|
||||
Folder of audio files…
|
||||
<span class="hint">tag or natural order</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromText}>
|
||||
<Icon name="pencil" size={15} />
|
||||
Paste a tracklist…
|
||||
<span class="hint">plain lines or timestamped setlist</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if playlistStore.playlists.length}
|
||||
<div class="stats">
|
||||
<span class="stat">
|
||||
<b>{playlistStore.playlists.length}</b>
|
||||
<i>{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}</i>
|
||||
</span>
|
||||
<span class="stat-div"></span>
|
||||
<span class="stat"><b>{totals.tracks.toLocaleString()}</b><i>{totals.tracks === 1 ? "track" : "tracks"}</i></span>
|
||||
{#if totals.secs}
|
||||
<span class="stat-div"></span>
|
||||
<span class="stat"><b>{fmtDuration(totals.secs)}</b><i>of music</i></span>
|
||||
{/if}
|
||||
{#if pinned.length}
|
||||
<span class="stat-div"></span>
|
||||
<span class="stat"><b>{pinned.length}</b><i>pinned</i></span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn primary" onclick={newPlaylist} disabled={creating}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if playlistStore.playlists.length === 0}
|
||||
@@ -353,63 +453,147 @@
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* The Library page's own frame: the blurred ambience pins behind the
|
||||
glass bar, so the page starts at the very top of the pane and carries
|
||||
its own inset instead of taking padding-top from the BAR_VIEWS rule
|
||||
(the Library exception, documented at the set in App.svelte). The
|
||||
isolation keeps the z-indexed washes inside this page's own stacking
|
||||
context — without it they escape past the transparent pane and paint
|
||||
behind the app's opaque black. */
|
||||
.page {
|
||||
padding: 8px 4px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 3px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
--pad-top: calc(var(--bar-h, 46px) + 14px);
|
||||
padding: var(--pad-top) 0 40px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
/* ------------------------------------------------------------ ambience
|
||||
The Library's recipe at this page's colour: blurred cover, faded out
|
||||
before the cards; z-index 0 lies OVER the in-flow header (that is the
|
||||
approved look — the h1 carries a text-shadow for it), pointer-events
|
||||
none so nothing under it goes dead. */
|
||||
.ambience {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
right: -28px;
|
||||
top: calc(-1 * var(--pad-top));
|
||||
height: 430px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-size: cover;
|
||||
background-position: center 30%;
|
||||
filter: blur(90px) saturate(1.5);
|
||||
opacity: 0.2;
|
||||
transform: translateZ(0);
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 35%, transparent);
|
||||
mask-image: linear-gradient(180deg, #000 35%, transparent);
|
||||
}
|
||||
.ambience-glow {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
right: -28px;
|
||||
top: calc(-1 * var(--pad-top));
|
||||
height: 300px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
640px 260px at 14% 0%,
|
||||
color-mix(in srgb, var(--accent) 13%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- hero */
|
||||
.hero {
|
||||
padding-top: 4px;
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 31px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* The Library's tool button: translucent elevated glass, one height. */
|
||||
.tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
background 0.14s,
|
||||
color 0.14s,
|
||||
transform 0.14s;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--bg-highlight);
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: var(--bg-press);
|
||||
.tool:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
.tool:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.tool.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #08080a;
|
||||
}
|
||||
.tool.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
color: #08080a;
|
||||
}
|
||||
|
||||
/* The stat row — the Library's value/label idiom for the collection. */
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 15px 0 20px;
|
||||
}
|
||||
.stat {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stat b {
|
||||
font-size: 15.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat i {
|
||||
font-style: normal;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-div {
|
||||
width: 1px;
|
||||
height: 17px;
|
||||
flex: none;
|
||||
background: linear-gradient(rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
|
||||
@@ -1,10 +1,71 @@
|
||||
<script>
|
||||
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
|
||||
import { setWordFill } from "../lib/lyrics.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
|
||||
let settings = $state(null);
|
||||
let saving = $state(false);
|
||||
// Lyric alignment model (Tier B word timing) — downloaded only from the
|
||||
// explicit click below, per the preparation-worker consent charter.
|
||||
let lyricModel = $state(null);
|
||||
let modelBusy = $state(false);
|
||||
let modelPct = $state(0);
|
||||
let modelError = $state("");
|
||||
// Vocal isolation model (ctc-v5 stem tier) — same consent rule, its own
|
||||
// row below, shown only once the aligner model above exists.
|
||||
let sepModel = $state(null);
|
||||
let sepBusy = $state(false);
|
||||
let sepPct = $state(0);
|
||||
let sepError = $state("");
|
||||
void api.lyricModelStatus().then((s) => (lyricModel = s)).catch(() => {});
|
||||
void api.vocalSepStatus().then((s) => (sepModel = s)).catch(() => {});
|
||||
$effect(() => {
|
||||
let unlisten = null;
|
||||
let unlistenSep = null;
|
||||
void import("@tauri-apps/api/event").then(({ listen }) => {
|
||||
listen("lyrics:model-progress", (e) => {
|
||||
const p = e.payload ?? {};
|
||||
if (p.total > 0) modelPct = Math.round((p.received / p.total) * 100);
|
||||
}).then((u) => (unlisten = u));
|
||||
listen("lyrics:sep-model-progress", (e) => {
|
||||
const p = e.payload ?? {};
|
||||
if (p.total > 0) sepPct = Math.round((p.received / p.total) * 100);
|
||||
}).then((u) => (unlistenSep = u));
|
||||
});
|
||||
return () => {
|
||||
unlisten?.();
|
||||
unlistenSep?.();
|
||||
};
|
||||
});
|
||||
async function downloadModel() {
|
||||
if (modelBusy) return;
|
||||
modelBusy = true;
|
||||
modelError = "";
|
||||
modelPct = 0;
|
||||
try {
|
||||
await api.lyricModelDownload();
|
||||
lyricModel = await api.lyricModelStatus();
|
||||
} catch (e) {
|
||||
modelError = String(e).slice(0, 160);
|
||||
} finally {
|
||||
modelBusy = false;
|
||||
}
|
||||
}
|
||||
async function downloadSepModel() {
|
||||
if (sepBusy) return;
|
||||
sepBusy = true;
|
||||
sepError = "";
|
||||
sepPct = 0;
|
||||
try {
|
||||
await api.vocalSepDownload();
|
||||
sepModel = await api.vocalSepStatus();
|
||||
} catch (e) {
|
||||
sepError = String(e).slice(0, 160);
|
||||
} finally {
|
||||
sepBusy = false;
|
||||
}
|
||||
}
|
||||
let savedFlash = $state(false);
|
||||
let saveError = $state("");
|
||||
let refreshingArtists = $state(false);
|
||||
@@ -44,6 +105,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function setWordTiming(next) {
|
||||
if (next !== settings.word_timing) {
|
||||
await commit({ wordTiming: next });
|
||||
// Applies live: surfaces stop/start rendering word markup
|
||||
// reactively, and turning it on asks for the current track.
|
||||
setWordFill(next);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseDownloadDir() {
|
||||
const dir = await api.pickFolder();
|
||||
if (dir) await commit({ downloadDir: dir });
|
||||
@@ -250,6 +320,79 @@
|
||||
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Lyrics</h2>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Word-by-word fill <span class="exp-tag">Experimental</span></div>
|
||||
<div class="hint">
|
||||
Fills each word as it is sung, karaoke-style. Still experimental: timing
|
||||
can drift on dense or echo-heavy songs. Off keeps the reliable
|
||||
line-by-line sync everywhere and pauses the background word analysis.
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={settings.word_timing ? "on" : "off"}
|
||||
disabled={saving}
|
||||
onchange={(e) => setWordTiming(e.currentTarget.value === "on")}
|
||||
>
|
||||
<option value="off">Off (recommended for now)</option>
|
||||
<option value="on">On</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if settings.word_timing}
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Precise word timing</div>
|
||||
<div class="hint">
|
||||
Downloads an alignment model (MMS, ~1.26 GB, from Hugging Face) that
|
||||
listens to each lyric line and times every word against the actual singing,
|
||||
in most languages. Runs entirely on this computer, in the background, never
|
||||
against the playing track. Without it, word timing stays a lighter estimate.
|
||||
Already-timed songs re-prepare automatically the next time you open their
|
||||
lyrics.
|
||||
</div>
|
||||
</div>
|
||||
{#if lyricModel?.present}
|
||||
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
|
||||
{:else}
|
||||
<button class="model-btn" disabled={modelBusy} onclick={downloadModel}>
|
||||
{modelBusy ? `Downloading… ${modelPct}%` : "Download (1.26 GB)"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if modelError}
|
||||
<p class="muted small">Download failed: {modelError}</p>
|
||||
{/if}
|
||||
{#if lyricModel?.present}
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Vocal isolation</div>
|
||||
<div class="hint">
|
||||
Downloads a small separation model (UVR MDX-Net Voc FT, ~64 MB, from
|
||||
the Ultimate Vocal Remover project) that lifts the singing away from
|
||||
the band before words are timed — noticeably steadier timing on dense
|
||||
mixes. Same rules as above: runs on this computer, in the background,
|
||||
never against the playing track. Timed songs re-prepare the next time
|
||||
you open their lyrics.
|
||||
</div>
|
||||
</div>
|
||||
{#if sepModel?.present}
|
||||
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
|
||||
{:else}
|
||||
<button class="model-btn" disabled={sepBusy} onclick={downloadSepModel}>
|
||||
{sepBusy ? `Downloading… ${sepPct}%` : "Download (64 MB)"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sepError}
|
||||
<p class="muted small">Download failed: {sepError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Streaming</h2>
|
||||
<div class="row">
|
||||
@@ -736,4 +879,44 @@
|
||||
.save-error {
|
||||
color: #f15e6c;
|
||||
}
|
||||
|
||||
.model-btn {
|
||||
flex: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.model-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.model-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
.exp-tag {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
|
||||
.model-ok {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #3ecf7e;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user