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

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