player bar: one fixed name box — the transport never moves again

The content-hugging cluster made the whole bar jump between songs; the
capped-and-scrolling mockups read as either dead space or perpetual
motion. What stuck (namehover.html, 2026-08-03): the name box is a
CONSTANT 240px — meta width and NAME_BOX_PX, paired — so the transport,
the lane, everything right of the cover sits at the same pixel whatever
plays. Verified across a long library title and a short cached one:
meta 240, cluster 291, play button x 387, identical every time.

A name that fits simply sits in the box. A longer one rests under an
edge fade — a gradient mask, never an ellipsis; every truncation rule
the old layout needed (the 55% artist ceiling, the album-gives-way flex,
the ellipsis trio) is deleted, because the box owns clipping now. While
the pointer is over a clipped name it glides out at 40px/s, holds 1.1s,
glides back, repeats; it eases home the moment you leave, and the
gradient only ever sits on the side actually hiding text. Both lines —
title and artist · album — glide independently, and the tooltips still
carry the full words.
This commit is contained in:
KeelrLeek committed 2026-08-03 18:03:13 -05:00
1 parent a949c24677
commit 2a83615958
1 file changed
+209 -67
+209 -67
View File
@@ -76,6 +76,142 @@
// how much a peer had sent. Nothing reads `player.streamSpeed`,
// `streamTotal` or `currentItem.quality` in this component any more.
// ---- the fixed name box ------------------------------------------------
// ONE constant width (picked on research/playbar-redesign/namehover.html,
// 2026-08-03): the transport is welded in place no matter what plays. A
// name that fits simply sits in the box; a longer one rests under an edge
// fade — a mask, never "…" — and glides ONLY while the pointer is over
// it: out, hold, back, repeat, then eases home on leave. The tooltip
// still carries the full text. NAME_BOX_PX pairs with `.meta`'s width in
// the styles below; change them together.
const NAME_BOX_PX = 240;
const GLIDE_PX_PER_S = 40;
const GLIDE_HOLD_MS = 1100;
const GLIDE_START_DELAY_MS = 320;
function nameGlide(node) {
const inner = node.firstElementChild;
let overflow = 0;
let x = 0;
let phase = "idle";
let phaseT0 = 0;
let homeFrom = 0;
let hovered = false;
let raf = null;
const easeIO = (k) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);
const paint = () => {
inner.style.transform = x ? `translateX(${x.toFixed(1)}px)` : "";
const right = overflow > 0 && x > -overflow + 1;
const left = overflow > 0 && x < -1;
node.classList.toggle("maskLR", left && right);
node.classList.toggle("maskR", right && !left);
node.classList.toggle("maskL", left && !right);
};
const step = () => {
raf = null;
const now = performance.now();
const dt = now - phaseT0;
const dur = Math.max(200, (overflow / GLIDE_PX_PER_S) * 1000);
switch (phase) {
case "hold0":
x = 0;
if (dt > GLIDE_START_DELAY_MS) {
phase = "out";
phaseT0 = now;
}
break;
case "out": {
const k = Math.min(1, dt / dur);
x = -overflow * easeIO(k);
if (k >= 1) {
phase = "hold1";
phaseT0 = now;
}
break;
}
case "hold1":
x = -overflow;
if (dt > GLIDE_HOLD_MS) {
phase = "back";
phaseT0 = now;
}
break;
case "back": {
const k = Math.min(1, dt / dur);
x = -overflow * (1 - easeIO(k));
if (k >= 1) {
x = 0;
phase = hovered ? "hold0" : "idle";
phaseT0 = now;
}
break;
}
case "home": {
const hdur = Math.max(120, (Math.abs(homeFrom) / (GLIDE_PX_PER_S * 2)) * 1000);
const k = Math.min(1, dt / hdur);
x = homeFrom * (1 - easeIO(k));
if (k >= 1) {
x = 0;
phase = "idle";
}
break;
}
}
paint();
if (phase !== "idle") raf = requestAnimationFrame(step);
};
const arm = () => {
if (raf === null) raf = requestAnimationFrame(step);
};
const measure = () => {
overflow = Math.max(0, Math.ceil(inner.scrollWidth - node.clientWidth));
x = 0;
phase = "idle";
if (raf !== null) {
cancelAnimationFrame(raf);
raf = null;
}
paint();
};
const onEnter = () => {
hovered = true;
if (overflow > 0 && (phase === "idle" || phase === "home")) {
phase = "hold0";
phaseT0 = performance.now();
arm();
}
};
const onLeave = () => {
hovered = false;
if (phase !== "idle" && phase !== "home") {
homeFrom = x;
phase = "home";
phaseT0 = performance.now();
arm();
}
};
node.addEventListener("mouseenter", onEnter);
node.addEventListener("mouseleave", onLeave);
// Track changes swap the text out from under the box; re-measure a
// beat later so the DOM (and on first load, the font) has settled.
const remeasure = () => queueMicrotask(measure);
const observer = new MutationObserver(remeasure);
observer.observe(inner, { childList: true, characterData: true, subtree: true });
window.addEventListener("resize", remeasure);
document.fonts?.ready.then(remeasure);
measure();
return {
destroy() {
observer.disconnect();
window.removeEventListener("resize", remeasure);
node.removeEventListener("mouseenter", onEnter);
node.removeEventListener("mouseleave", onLeave);
if (raf !== null) cancelAnimationFrame(raf);
},
};
}
// ---- the overflow menu -------------------------------------------------
let menuOpen = $state(false);
let menuWrap = $state(null);
@@ -780,15 +916,19 @@
</div>
{/if}
<div class="meta">
{#if player.error}
<span class="title err">⚠ {player.error}</span>
{:else if player.track}
<button class="title link" onclick={openCurrentSong} title={player.track.title}>
{player.track.title}
</button>
{:else}
<span class="title muted">Nothing playing</span>
{/if}
<div class="namebox" use:nameGlide>
<span class="glider">
{#if player.error}
<span class="title err">⚠ {player.error}</span>
{:else if player.track}
<button class="title link" onclick={openCurrentSong} title={player.track.title}>
{player.track.title}
</button>
{:else}
<span class="title muted">Nothing playing</span>
{/if}
</span>
</div>
<!-- Artist and album on ONE line, which is what a 62px bar can pay
for. The `.who` idiom from the album page: a 50%-opacity middot
@@ -799,23 +939,27 @@
clickable line renders, buffering or not: gating it on
status=playing made the album flash in a beat after the
title and artist. -->
{#if player.track && !player.track.path}
<span class="who"><span class="sub">{player.track.sub || "buffering…"}</span></span>
{:else if player.track && (metaParts[0] || metaParts[1])}
<span class="who">
{#if metaParts[0]}
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
{metaParts[0]}
</button>
{/if}
{#if metaParts[0] && metaParts[1]}<span class="sep">·</span>{/if}
{#if metaParts[1]}
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
{metaParts[1]}
</button>
<div class="namebox" use:nameGlide>
<span class="glider">
{#if player.track && !player.track.path}
<span class="who"><span class="sub">{player.track.sub || "buffering…"}</span></span>
{:else if player.track && (metaParts[0] || metaParts[1])}
<span class="who">
{#if metaParts[0]}
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
{metaParts[0]}
</button>
{/if}
{#if metaParts[0] && metaParts[1]}<span class="sep">·</span>{/if}
{#if metaParts[1]}
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
{metaParts[1]}
</button>
{/if}
</span>
{/if}
</span>
{/if}
</div>
</div>
</div>
@@ -961,20 +1105,17 @@
border-radius: var(--radius);
}
/* FIXED width, deliberately: the name box is a constant (picked on
research/playbar-redesign/namehover.html, 2026-08-03), so the
transport, the lane — everything right of here — sits at the same
pixel on every track. The earlier content-hugging cluster read as the
whole bar jumping between songs. Long names glide under the pointer
instead of resizing anything; see nameGlide in the script. */
.now-playing {
display: flex;
align-items: center;
gap: 11px;
min-width: 170px;
/* The song name sets this width, uncapped. `0 1 auto` means the cluster
sits at its natural content width and gives ground ONLY once the
scrubber has reached its 260px floor — at which point the title
ellipsises, and not before. Chosen off
`research/playbar-redesign/width-rule.html` against a capped variant:
the cap bounded how far the transport travelled between tracks (525px
vs 597px on the longest title in that set) at the cost of truncating
names that had room to be read. The name won. */
flex: 0 1 auto;
flex: none;
}
/* 40px, the size of a sidebar recents cover, for the same reason. */
@@ -1010,41 +1151,46 @@
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
/* NAME_BOX_PX in the script — change them together. */
width: 240px;
gap: 1px;
}
/* The clipping window a long name glides inside. The gradient masks are
the "there is more" signal — never an ellipsis — and only the side(s)
actually hiding text get one, toggled by nameGlide as it moves. */
.namebox {
position: relative;
width: 100%;
overflow: hidden;
}
.glider {
display: inline-block;
white-space: nowrap;
will-change: transform;
}
.namebox.maskR {
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 22px), transparent);
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 22px), transparent);
}
.namebox.maskL {
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 100%);
mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 100%);
}
.namebox.maskLR {
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 calc(100% - 22px), transparent);
mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 calc(100% - 22px), transparent);
}
/* Artist and album on one line — the `.who` idiom, copied rather than
invented: separator at 50% opacity, underline only on hover. A third
line is what 62px cannot pay for. */
line is what 62px cannot pay for. Lays out at its NATURAL width inside
the glider: the name box clips and glides, so nothing here shrinks,
truncates, or gives way any more. */
.who {
display: flex;
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
min-width: 0;
}
/* When the line does have to give, the ALBUM gives. Shrinking both in
proportion turned a short artist into "Po…", which is unreadable and
saves almost nothing; the album is the longer half and the one you can
lose the tail of and still recognise. */
.who .sub {
min-width: 0;
}
/* ...but ONLY when there is an album to give way to. `55%` resolves against
`.who`, whose own width is shrink-to-fit of its children — so when the
artist is the only child, the cap is 55% of the artist's own width and it
eats itself: "M83" rendered as "M…" in a 12px box. That is exactly the
state the bar is in while a track loads, where the buffering branch puts
one child in this line. Measured, not reasoned: 23px of text in a 12px
box, with and without the old cluster cap, so this predates that change.
Alone, it keeps the default `0 1 auto` and ellipsises like anything else. */
.who .sub:not(.album):not(:only-child) {
flex: 0 0 auto;
max-width: 55%;
}
.who .sub.album {
flex: 0 1 auto;
}
.who .sep {
opacity: 0.5;
@@ -1053,16 +1199,12 @@
color: var(--text-secondary);
}
/* Separate, clickable meta lines. Buttons are width-constrained so the
hover underline only spans the text, and ellipsis-truncate. */
/* Separate, clickable meta lines, at natural width — the name box owns
clipping now, so no ellipsis anywhere. */
.title,
.sub {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
display: block;
}
.title {