dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice

This commit is contained in:
KeelrLeek committed 2026-08-24 10:49:10 -05:00
1 parent 4efe85b6bf
commit 1dd8573808
41 files changed
+9288 -647

No files matched your search

+285 -101
View File
@@ -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 {