playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -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}
|
||||
 · <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>
|
||||
Reference in new issue
Block a user