Files
SoulStream/src/components/SaveMenu.svelte
T
KeelrLeek 913289a297 the panel tops speak one language: 28px discs, white glyphs, one veil
Kyle put four crops side by side and asked why the top chrome disagrees
with itself. The audit found five variants across three panel tops: the
glass bar's arrows (28px circle, white, 17px glyph, 0.12 white-veil
hover), the sidebar toggle and both Now Playing chevrons (28px 6px-radius
squares, grey, 16px, opaque --bg-highlight hover that slabs under glass
and a --glass-hover override that reads as nothing up here), the
add-to-playlist trigger (grey square, 15px glyph), and the save button
(34px 8px-radius square, 18px glyph).

One spec now, the arrows' language: 28x28 circle, 16px glyph, #fff at
rest, rgba(255,255,255,.12) hover in BOTH modes. This commit carries the
Now Playing header set — np-icon (head collapse + rail expand), the
plm-trigger override, and a new np-head override pulling SaveMenu's
trigger down from its 34px playbar size (SaveMenu grows an iconSize prop;
the playbar keeps 18, the head passes 16). The glass-hover overrides for
these buttons are gone — the base veil is mode-independent — except one
glass-scoped rule kept solely to outrank SaveMenu's own glass hover on
specificity. The bar arrows' 17->16 and the sidebar toggle's disc ride the
hub commit; their files carry that rework and split poorly.

Verified by computed style across all five controls: identical to the
pixel, and the disabled arrows stay dimmed white rather than grey so even
the off state is the same hue family.
2026-08-31 15:13:24 -05:00

181 lines
5.8 KiB
Svelte

<script>
import { player, saveStream } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
// align hint: "down" (panel) or "up" (player bar). Auto-flips if needed.
// iconSize: the playbar keeps the 18px transport glyph; the Now Playing
// header passes 16 to match the panel-top chrome spec (28px circle,
// 16px glyph — see the np-head overrides there).
let { align = "down", iconSize = 18 } = $props();
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
const isStream = $derived(!!currentItem?.stream);
const streamCount = $derived(player.queue.filter((it) => it.stream).length);
const isAlbum = $derived(streamCount > 1);
let triggerEl = $state(null);
let open = $state(false);
let pos = $state({ top: 0, left: 0 });
let toast = $state("");
let toastPos = $state({ top: 0, left: 0 });
let toastTimer;
const MENU_W = 190;
function place() {
const r = triggerEl.getBoundingClientRect();
const h = 92; // two items
let left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
let top = align === "up" ? r.top - 6 - h : r.bottom + 6;
if (align !== "up" && top + h > window.innerHeight - 8) top = r.top - 6 - h;
if (align === "up" && top < 8) top = r.bottom + 6;
pos = { top, left };
}
async function save(scope) {
open = false;
const res = await saveStream(scope);
if (!res.msg) return;
const r = triggerEl.getBoundingClientRect();
toastPos = {
top: align === "up" ? r.top - 34 : r.bottom + 6,
left: Math.max(8, r.right - 190),
};
toast = res.msg;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ""), 2600);
}
function onClick(e) {
e.stopPropagation();
// Single track: one destination, save straight away. Album: choose scope.
if (!isAlbum) {
save("track");
return;
}
if (open) {
open = false;
return;
}
place();
open = true;
}
$effect(() => {
if (!open) return;
const onDoc = (e) => {
if (!e.target.closest(".save-wrap") && !e.target.closest(".save-pop")) open = false;
};
const close = () => (open = false);
document.addEventListener("click", onDoc, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("click", onDoc, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
});
</script>
{#if isStream}
<span class="save-wrap">
<button
bind:this={triggerEl}
class="save-icon"
class:on={open}
title="Save to library"
onclick={onClick}
>
<Icon name="download" size={iconSize} />
</button>
</span>
{#if open}
<div class="save-pop menu" style="top:{pos.top}px; left:{pos.left}px;">
<button class="mi" onclick={() => save("track")}>
<Icon name="music" size={16} /> Save this track
</button>
<button class="mi" onclick={() => save("album")}>
<Icon name="library" size={16} /> Save whole album
</button>
</div>
{/if}
{#if toast}
<div class="save-pop toast" style="top:{toastPos.top}px; left:{toastPos.left}px;">
{toast}
</div>
{/if}
{/if}
<style>
.save-wrap { display: inline-flex; }
.save-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
color: var(--text-secondary);
}
.save-icon:hover,
.save-icon.on {
color: var(--text-primary);
background: var(--bg-highlight);
}
/* Fixed positioning escapes the panel's overflow clipping and any
stacking context, so the popover always sits on top. */
.save-pop {
position: fixed;
z-index: 1000;
}
.menu {
width: 190px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 16px 38px -12px rgba(0, 0, 0, 0.75);
}
.mi {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--text-primary);
text-align: left;
}
.mi:hover { background: var(--bg-highlight); }
.mi :global(.icon) { color: var(--text-secondary); flex: 0 0 auto; }
.toast {
white-space: nowrap;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
color: var(--text-primary);
box-shadow: 0 12px 26px -10px rgba(0, 0, 0, 0.75);
}
/* ── dark glass ────────────────────────────────────────────────────────
Only the TRIGGER. .save-icon is a 34px icon button that lives on the
page — album actions, the Now Playing head — so it stands on pane frost
and its opaque --bg-highlight hover read as a slab there. The popover
and its toast below it stay solid on purpose: a menu has to be readable
over whatever it lands on (the Settings copy for this mode says so). */
:global(:root.glass) .save-icon:hover,
:global(:root.glass) .save-icon.on {
background: var(--glass-hover);
}
</style>