From 2a83615958cda45a352ecf26f33af6ffbe2dfbd8 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:03:13 -0500 Subject: [PATCH] =?UTF-8?q?player=20bar:=20one=20fixed=20name=20box=20?= =?UTF-8?q?=E2=80=94=20the=20transport=20never=20moves=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/PlayerBar.svelte | 276 ++++++++++++++++++++++++-------- 1 file changed, 209 insertions(+), 67 deletions(-) diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index ac47d28..b3b5008 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -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 @@ {/if}
@@ -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 {