player bar: the overflow menu out from under the bar's own clip, and Lyrics wired for real

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:36 -05:00
1 parent df3d15553a
commit f4f26d6d34
1 file changed
+79 -36
+79 -36
View File
@@ -215,25 +215,58 @@
}
// ---- the overflow menu -------------------------------------------------
// The popup renders OUTSIDE the footer and position:fixed (the SaveMenu
// idiom): the bar is overflow:hidden, so an absolute menu inside it got
// amputated at the bar's top edge — and what poked out still lost to the
// Now Playing panel's z-indexed layers. Fixed + measured coordinates
// escape both the clip and the stacking war. It must NOT live inside
// .extras: that element's centering transform would become a fixed
// element's containing block and hand it right back to the clip.
let menuOpen = $state(false);
let menuWrap = $state(null);
let menuEl = $state(null);
let menuPos = $state({ right: 0, bottom: 0 });
function toggleMenu() {
if (!menuOpen && menuWrap) {
const r = menuWrap.getBoundingClientRect();
// Right-aligned over the button, opening upward — the same seat
// the old absolute placement (bottom + right of the wrap) gave it.
menuPos = {
right: Math.max(8, window.innerWidth - r.right - 6),
bottom: window.innerHeight - r.top + 6,
};
}
menuOpen = !menuOpen;
}
$effect(() => {
if (!menuOpen) return;
const away = (e) => {
if (menuWrap && !menuWrap.contains(e.target)) menuOpen = false;
const inside =
(menuWrap && menuWrap.contains(e.target)) ||
(menuEl && menuEl.contains(e.target));
if (!inside) menuOpen = false;
};
const esc = (e) => e.key === "Escape" && (menuOpen = false);
// The menu is anchored to coordinates measured at open; a resize
// moves the anchor out from under them, so it closes instead.
const close = () => (menuOpen = false);
window.addEventListener("pointerdown", away);
window.addEventListener("keydown", esc);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("pointerdown", away);
window.removeEventListener("keydown", esc);
window.removeEventListener("resize", close);
};
});
function openQueue() {
menuOpen = false;
showNowPlaying(); // the queue lives in the panel; always reveal it
}
function openLyrics() {
menuOpen = false;
ui.lyricsFull = true; // the fullscreen overlay (LyricsOverlay.svelte)
}
// ---- Save to library, from the ⋯ menu ---------------------------------
// Only a stream can be saved; a single queued stream has one destination
@@ -1224,40 +1257,10 @@
title="More"
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
onclick={toggleMenu}
>
<Icon name="more-horizontal" size={17} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<button role="menuitem" onclick={openQueue}>
<Icon name="playlist" size={15} /> Queue
</button>
<!-- Save's new home. The bar's download button became the
expand toggle, and this menu is where everything past
Save was always going to live. Only a stream can be
saved, so the items appear only for one. -->
{#if canSave}
<button role="menuitem" onclick={() => saveFromMenu("track")}>
<Icon name="music" size={15} /> Save this track
</button>
{#if savingAlbum}
<button role="menuitem" onclick={() => saveFromMenu("album")}>
<Icon name="library" size={15} /> Save whole album
</button>
{/if}
{/if}
<button role="menuitem" disabled title="Not built yet">
<Icon name="lyrics" size={15} /> Lyrics <span class="soon">soon</span>
</button>
<button role="menuitem" disabled title="Not built yet">
<Icon name="panel-right" size={15} /> Mini player <span class="soon">soon</span>
</button>
<button role="menuitem" disabled title="Not built yet">
<Icon name="volume" size={15} /> Output device <span class="soon">soon</span>
</button>
</div>
{/if}
</div>
<button class="vol-icon" title={player.muted ? "Unmute" : "Mute"} onclick={toggleMute}>
@@ -1283,6 +1286,45 @@
</div>
</footer>
<!-- Outside the footer on purpose: see the note at menuOpen. -->
{#if menuOpen}
<div
class="menu"
role="menu"
bind:this={menuEl}
style="right: {menuPos.right}px; bottom: {menuPos.bottom}px;"
>
<button role="menuitem" onclick={openQueue}>
<Icon name="playlist" size={15} /> Queue
</button>
<!-- Save's new home. The bar's download button became the expand
toggle, and this menu is where everything past Save was always
going to live. Only a stream can be saved, so the items appear
only for one. -->
{#if canSave}
<button role="menuitem" onclick={() => saveFromMenu("track")}>
<Icon name="music" size={15} /> Save this track
</button>
{#if savingAlbum}
<button role="menuitem" onclick={() => saveFromMenu("album")}>
<Icon name="library" size={15} /> Save whole album
</button>
{/if}
{/if}
<!-- Enabled only with a track: the overlay closes itself the moment
player.track is empty, so opening it then would just blink. -->
<button role="menuitem" disabled={!currentItem} onclick={openLyrics}>
<Icon name="lyrics" size={15} /> Lyrics
</button>
<button role="menuitem" disabled title="Not built yet">
<Icon name="panel-right" size={15} /> Mini player <span class="soon">soon</span>
</button>
<button role="menuitem" disabled title="Not built yet">
<Icon name="volume" size={15} /> Output device <span class="soon">soon</span>
</button>
</div>
{/if}
<style>
/* FOUR blocks, absolutely placed, so the collapsed row and the expanded
stack are the same layout at two sets of coordinates and the bar can
@@ -1708,10 +1750,11 @@
place-items: center;
}
.menu {
position: absolute;
bottom: 30px;
right: -6px;
z-index: 40;
/* Fixed positioning escapes the bar's overflow clipping and every
panel stacking context (the SaveMenu idiom, same tier). The
coordinates come measured from the trigger at open. */
position: fixed;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;