dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice
This commit is contained in:
1 parent
4efe85b6bf
commit
1dd8573808
41 files changed
+9288
-647
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user