forked from Keelrleek/SoulStream
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`,
|
||||
// `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 {
|
||||
|
||||
Reference in new issue
Block a user