281 lines
9.2 KiB
Svelte
281 lines
9.2 KiB
Svelte
<script>
|
|
// "Add to playlist" dropdown — one component for every surface (album
|
|
// 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";
|
|
|
|
let {
|
|
// () => [{ title, artist, album, durationSecs, path, artPath }]
|
|
tracks,
|
|
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();
|
|
});
|
|
|
|
function toggle(e) {
|
|
e.stopPropagation();
|
|
open = !open;
|
|
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 ?? "",
|
|
artist: t.artist ?? null,
|
|
album: t.album ?? null,
|
|
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,
|
|
}));
|
|
}
|
|
|
|
async function addTo(pl) {
|
|
if (busy) return;
|
|
const rows = payload().filter((t) => t.title);
|
|
if (!rows.length) return;
|
|
busy = true;
|
|
try {
|
|
await api.playlistAddItems(pl.id, rows);
|
|
doneFor = pl.id;
|
|
await refreshPlaylists();
|
|
setTimeout(() => (open = false), 650);
|
|
} catch (e) {
|
|
console.error("add to playlist failed", e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function addToNew() {
|
|
if (busy) return;
|
|
const rows = payload().filter((t) => t.title);
|
|
if (!rows.length) return;
|
|
busy = true;
|
|
try {
|
|
// Name the new playlist after what is being added when it reads
|
|
// like an album; otherwise fall back to the numbered default.
|
|
const first = rows[0];
|
|
const name =
|
|
rows.length > 1 && first.album
|
|
? first.album
|
|
: rows.length === 1
|
|
? first.title
|
|
: `New Playlist ${playlistStore.playlists.length + 1}`;
|
|
const id = await api.createPlaylist(name);
|
|
await api.playlistAddItems(id, rows);
|
|
doneFor = id;
|
|
await refreshPlaylists();
|
|
setTimeout(() => (open = false), 650);
|
|
} catch (e) {
|
|
console.error("add to new playlist failed", e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="plm" onkeydown={(e) => e.stopPropagation()}>
|
|
<button
|
|
bind:this={triggerEl}
|
|
class="plm-trigger"
|
|
class:compact
|
|
{title}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
onclick={toggle}
|
|
>
|
|
<Icon name="list-plus" size={iconSize} />
|
|
{#if !compact}<span>Add to playlist</span>{/if}
|
|
</button>
|
|
</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 {
|
|
position: relative;
|
|
display: inline-flex;
|
|
}
|
|
.plm-trigger {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
color: inherit;
|
|
}
|
|
.plm-trigger.compact {
|
|
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: 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: 1000;
|
|
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
|
}
|
|
.plm-list {
|
|
max-height: 260px;
|
|
overflow-y: auto;
|
|
}
|
|
.plm-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
width: 100%;
|
|
padding: 8px 10px;
|
|
border-radius: 7px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-align: left;
|
|
}
|
|
.plm-item:hover:not(:disabled) {
|
|
background: var(--bg-highlight);
|
|
color: var(--text-primary);
|
|
}
|
|
.plm-item:disabled {
|
|
opacity: 0.6;
|
|
}
|
|
.plm-item .nm {
|
|
flex: 1;
|
|
min-width: 0;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.plm-item .ct {
|
|
flex: none;
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.plm-item .ok {
|
|
color: #3ecf7e;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
.plm-rule {
|
|
height: 1px;
|
|
background: var(--border);
|
|
margin: 5px 8px;
|
|
}
|
|
</style>
|