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.
181 lines
5.8 KiB
Svelte
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>
|