playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+554
View File
@@ -0,0 +1,554 @@
<script>
// Staged playlist import: parse → resolve → REVIEW → one-transaction
// 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".
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
let {
open = $bindable(false),
mode = "file", // "file": a probed draft arrives via `draft`; "paste": textarea first
draft = null,
onimported = () => {},
} = $props();
let pasteText = $state("");
let localDraft = $state(null);
let name = $state("");
let includeUnresolved = $state(true);
let committing = $state(false);
let parsing = $state(false);
let error = $state("");
// The working draft: probed (file mode) or built from the paste box.
const working = $derived(localDraft ?? draft);
// Re-arm the form whenever the modal opens with a new draft.
$effect(() => {
if (!open) return;
error = "";
committing = false;
localDraft = null;
pasteText = "";
includeUnresolved = true;
name = draft?.name ?? "";
});
async function previewPaste() {
if (!pasteText.trim() || parsing) return;
parsing = true;
error = "";
try {
const parsed = await api.playlistImportText(pasteText);
if (!parsed.entries.length) {
error = "No tracks recognized in that text.";
} else {
localDraft = parsed;
if (!name) name = parsed.name ?? "";
}
} catch (e) {
error = String(e);
} finally {
parsing = false;
}
}
const kept = $derived.by(() => {
if (!working) return [];
return includeUnresolved
? working.entries
: working.entries.filter((e) => e.resolution === "local");
});
async function commit() {
if (!working || committing) return;
const finalName = (name || working.name || "Imported playlist").trim();
committing = true;
error = "";
try {
const id = await api.playlistImportCommit(
finalName,
working.description ?? null,
working.kind,
kept,
);
onimported(id, finalName);
} catch (e) {
error = String(e);
committing = false;
}
}
function close() {
open = false;
}
function fmtTime(ms) {
if (ms == null) return "";
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return h > 0
? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
: `${m}:${String(s).padStart(2, "0")}`;
}
function fmtDur(secs) {
if (!secs) return "";
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
function onkeydown(e) {
if (e.key === "Escape") close();
}
const FORMAT_LABEL = {
m3u: "M3U",
pls: "PLS",
xspf: "XSPF",
csv: "CSV",
soulstream: "SoulStream backup",
text: "Pasted text",
folder: "Folder",
};
</script>
<svelte:window onkeydown={open ? onkeydown : undefined} />
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="scrim" onclick={close} role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div
class="dialog"
role="dialog"
aria-modal="true"
aria-label="Import playlist"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
>
<header>
<h2>
{#if working}
Review import
{:else}
Paste a tracklist
{/if}
</h2>
<button class="x" title="Close" onclick={close}>
<Icon name="x" size={16} />
</button>
</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>
{: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
{#if working.unresolved_count > 0}
&thinsp;·&thinsp;<b class="un">{working.unresolved_count}</b> unresolved
{/if}
</span>
</div>
{#if working.warnings?.length}
<div class="warnings">
{#each working.warnings as w, wi (wi)}
<p><Icon name="alert" size={12} /> {w}</p>
{/each}
</div>
{/if}
<div class="rows" tabindex="-1">
{#each working.entries as e (e.ordinal)}
<div
class="row"
class:unresolved={e.resolution !== "local"}
class:excluded={!includeUnresolved && e.resolution !== "local"}
>
<span class="n">{e.ordinal + 1}</span>
{#if working.kind === "setlist"}
<span class="t">{fmtTime(e.start_ms)}</span>
{/if}
<span class="who" title={e.raw}>
<span class="ti">{e.title}</span>
{#if e.artist}<span class="ar">{e.artist}</span>{/if}
</span>
<span class="ev">
{#if e.resolution === "local"}
<span
class="tag ok"
title={e.matched_by === "path"
? "Matched by exact file path"
: e.matched_by === "relinked"
? "File found under the current library folder"
: "Matched by artist and title tags"}
>
<Icon name="check" size={11} />
{e.matched_by === "path" || e.matched_by === "file"
? "file"
: e.matched_by === "relinked"
? "relinked"
: "tags"}
</span>
{:else}
<span class="tag un" title="No local match — kept as a placeholder">
unresolved
</span>
{/if}
</span>
<span class="du">{fmtDur(e.duration_secs)}</span>
</div>
{/each}
</div>
<div class="form">
<input
class="name"
bind:value={name}
placeholder="Playlist name"
spellcheck="false"
/>
{#if working.unresolved_count > 0}
<label class="keep">
<input type="checkbox" bind:checked={includeUnresolved} />
Keep unresolved entries as placeholders
</label>
{/if}
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
{#if mode === "paste"}
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
{:else}
<button class="btn ghost" onclick={close}>Cancel</button>
{/if}
<button
class="btn primary"
onclick={commit}
disabled={committing || kept.length === 0}
>
{committing
? "Creating…"
: `Create playlist (${kept.length} ${kept.length === 1 ? "track" : "tracks"})`}
</button>
</footer>
{/if}
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 120;
background: rgba(0, 0, 0, 0.6);
display: grid;
place-items: center;
backdrop-filter: blur(3px);
}
.dialog {
width: min(640px, calc(100vw - 64px));
max-height: min(78vh, 760px);
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
h2 {
font-size: 18px;
font-weight: 700;
}
.x {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
}
.x:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.lead {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 10px;
}
.mono {
font-family: Consolas, monospace;
font-size: 12px;
color: var(--text-primary);
background: var(--bg-highlight);
border-radius: 4px;
padding: 1px 5px;
}
textarea {
width: 100%;
min-height: 220px;
resize: vertical;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
font-family: Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
outline: none;
}
textarea:focus {
border-color: var(--accent);
}
.srcline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
min-width: 0;
}
.chip {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
background: var(--bg-highlight);
color: var(--text-secondary);
padding: 3px 8px;
border-radius: 8px;
flex: none;
}
.chip.set {
background: var(--accent-soft);
color: var(--accent-hover);
}
.srcname {
font-size: 12.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.counts {
margin-left: auto;
flex: none;
font-size: 12px;
color: var(--text-muted);
}
.counts .ok {
color: #3ecf7e;
}
.counts .un {
color: #e8a33d;
}
.warnings {
margin-bottom: 8px;
}
.warnings p {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: #e8a33d;
}
.rows {
flex: 1;
min-height: 120px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-base);
padding: 4px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 5px;
font-size: 12.5px;
}
.row:hover {
background: var(--bg-highlight);
}
.row.excluded {
opacity: 0.35;
text-decoration: line-through;
}
.n {
width: 26px;
flex: none;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
font-size: 11px;
}
.t {
width: 52px;
flex: none;
color: var(--accent-hover);
font-variant-numeric: tabular-nums;
font-size: 11.5px;
}
.who {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ti {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ar {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev {
flex: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 700;
padding: 2.5px 7px;
border-radius: 8px;
}
.tag.ok {
background: rgba(62, 207, 126, 0.13);
color: #3ecf7e;
}
.tag.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;
}
.du {
width: 40px;
flex: none;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
font-size: 11.5px;
}
.form {
display: flex;
align-items: center;
gap: 14px;
margin-top: 12px;
}
.name {
flex: 1;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
font-size: 13.5px;
font-weight: 600;
outline: none;
}
.name:focus {
border-color: var(--accent);
}
.keep {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
flex: none;
}
.keep input {
accent-color: var(--accent);
}
.error {
margin-top: 8px;
font-size: 12.5px;
color: #f15e6c;
}
footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 14px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: 20px;
font-size: 13px;
font-weight: 700;
transition: background 0.14s;
}
.btn.primary {
background: var(--accent);
color: #000;
}
.btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn.primary:disabled {
opacity: 0.5;
}
.btn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.btn.ghost:hover {
background: var(--bg-press);
color: var(--text-primary);
}
</style>