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:
1 parent
a949c24677
commit
2a83615958
1 file changed
+209
-67
+209
-67
@@ -76,6 +76,142 @@
|
|||||||
// how much a peer had sent. Nothing reads `player.streamSpeed`,
|
// how much a peer had sent. Nothing reads `player.streamSpeed`,
|
||||||
// `streamTotal` or `currentItem.quality` in this component any more.
|
// `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 -------------------------------------------------
|
// ---- the overflow menu -------------------------------------------------
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let menuWrap = $state(null);
|
let menuWrap = $state(null);
|
||||||
@@ -780,15 +916,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{#if player.error}
|
<div class="namebox" use:nameGlide>
|
||||||
<span class="title err">⚠ {player.error}</span>
|
<span class="glider">
|
||||||
{:else if player.track}
|
{#if player.error}
|
||||||
<button class="title link" onclick={openCurrentSong} title={player.track.title}>
|
<span class="title err">⚠ {player.error}</span>
|
||||||
{player.track.title}
|
{:else if player.track}
|
||||||
</button>
|
<button class="title link" onclick={openCurrentSong} title={player.track.title}>
|
||||||
{:else}
|
{player.track.title}
|
||||||
<span class="title muted">Nothing playing</span>
|
</button>
|
||||||
{/if}
|
{: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
|
<!-- 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
|
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
|
clickable line renders, buffering or not: gating it on
|
||||||
status=playing made the album flash in a beat after the
|
status=playing made the album flash in a beat after the
|
||||||
title and artist. -->
|
title and artist. -->
|
||||||
{#if player.track && !player.track.path}
|
<div class="namebox" use:nameGlide>
|
||||||
<span class="who"><span class="sub">{player.track.sub || "buffering…"}</span></span>
|
<span class="glider">
|
||||||
{:else if player.track && (metaParts[0] || metaParts[1])}
|
{#if player.track && !player.track.path}
|
||||||
<span class="who">
|
<span class="who"><span class="sub">{player.track.sub || "buffering…"}</span></span>
|
||||||
{#if metaParts[0]}
|
{:else if player.track && (metaParts[0] || metaParts[1])}
|
||||||
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
|
<span class="who">
|
||||||
{metaParts[0]}
|
{#if metaParts[0]}
|
||||||
</button>
|
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
|
||||||
{/if}
|
{metaParts[0]}
|
||||||
{#if metaParts[0] && metaParts[1]}<span class="sep">·</span>{/if}
|
</button>
|
||||||
{#if metaParts[1]}
|
{/if}
|
||||||
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
|
{#if metaParts[0] && metaParts[1]}<span class="sep">·</span>{/if}
|
||||||
{metaParts[1]}
|
{#if metaParts[1]}
|
||||||
</button>
|
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
|
||||||
|
{metaParts[1]}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -961,20 +1105,17 @@
|
|||||||
border-radius: var(--radius);
|
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 {
|
.now-playing {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
min-width: 170px;
|
flex: none;
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 40px, the size of a sidebar recents cover, for the same reason. */
|
/* 40px, the size of a sidebar recents cover, for the same reason. */
|
||||||
@@ -1010,41 +1151,46 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
min-width: 0;
|
/* NAME_BOX_PX in the script — change them together. */
|
||||||
|
width: 240px;
|
||||||
gap: 1px;
|
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
|
/* Artist and album on one line — the `.who` idiom, copied rather than
|
||||||
invented: separator at 50% opacity, underline only on hover. A third
|
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 {
|
.who {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
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 {
|
.who .sep {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -1053,16 +1199,12 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Separate, clickable meta lines. Buttons are width-constrained so the
|
/* Separate, clickable meta lines, at natural width — the name box owns
|
||||||
hover underline only spans the text, and ellipsis-truncate. */
|
clipping now, so no ellipsis anywhere. */
|
||||||
.title,
|
.title,
|
||||||
.sub {
|
.sub {
|
||||||
max-width: 100%;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|||||||
Reference in new issue
Block a user