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

+109 -40
View File
@@ -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;