Skip to content

Commit 3e4c4f2

Browse files
adurrrwavecast
andauthored
feat: scroll long episode titles in footer player (#63) (#66)
Long titles in the persistent footer player now animate horizontally so the full text is visible. Short titles render normally. Honors prefers-reduced-motion: reduce by falling back to ellipsis truncation. Co-authored-by: wavecast <wavecast@users.noreply.github.com>
1 parent f81fd75 commit 3e4c4f2

6 files changed

Lines changed: 529 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1818

1919
- One-time console log on init indicating which persistence adapter is
2020
active (helps diagnose "footer disappears on navigation" issue).
21+
- Footer player horizontally scrolls long episode titles when they overflow,
22+
with a `prefers-reduced-motion: reduce` fallback to ellipsis (#63).
2123

2224
## [1.3.0] - 2026-06-16
2325

assets/js/podcast-player.js

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,6 +1529,7 @@ class PodcastFooter extends HTMLElement {
15291529

15301530
// Sync UI with current audio state (audio may have survived reconnection)
15311531
this._syncUI();
1532+
this._setupTitleMarquee();
15321533
}
15331534

15341535
/** Sync all UI elements with the current audio state. */
@@ -1548,6 +1549,42 @@ class PodcastFooter extends HTMLElement {
15481549
this._onTimeUpdate(); // sync progress/time display
15491550
}
15501551

1552+
/**
1553+
* Measure the footer title for overflow and toggle the marquee animation.
1554+
* Called from connectedCallback (initial mount) and after every title change.
1555+
* Uses ResizeObserver to re-measure on container resize / font load.
1556+
* No-op if either element is missing.
1557+
*/
1558+
_setupTitleMarquee() {
1559+
const wrap = this._els.title; // .title — clipped container
1560+
const inner = this._els.titleText; // .title-text — scrollable span
1561+
if (!wrap || !inner) return;
1562+
1563+
const measure = () => {
1564+
// Threshold of 8px filters out sub-pixel rounding noise; below that the
1565+
// animation would be imperceptible but still cost compositor work.
1566+
const overflow = inner.scrollWidth - wrap.clientWidth;
1567+
if (overflow > 8) {
1568+
wrap.style.setProperty("--marquee-distance", overflow + "px");
1569+
const duration = Math.min(20, Math.max(6, overflow / 40));
1570+
wrap.style.setProperty("--marquee-duration", duration + "s");
1571+
wrap.setAttribute("data-overflow", "");
1572+
} else {
1573+
wrap.removeAttribute("data-overflow");
1574+
wrap.style.removeProperty("--marquee-distance");
1575+
wrap.style.removeProperty("--marquee-duration");
1576+
}
1577+
};
1578+
1579+
// rAF: clientWidth/scrollWidth aren't reliable until after first paint of the new text.
1580+
requestAnimationFrame(measure);
1581+
1582+
if (!this._titleResizeObserver && typeof ResizeObserver !== "undefined") {
1583+
this._titleResizeObserver = new ResizeObserver(() => requestAnimationFrame(measure));
1584+
this._titleResizeObserver.observe(wrap);
1585+
}
1586+
}
1587+
15511588
/** Set the mute button icon based on current muted/volume state. */
15521589
_setMuteIcon() {
15531590
if (this._audio.muted || this._audio.volume === 0) {
@@ -1574,6 +1611,8 @@ class PodcastFooter extends HTMLElement {
15741611
window.removeEventListener("beforeunload", this._onBeforeUnload);
15751612
this._unbindAudioEvents();
15761613
this._unbindUIEvents();
1614+
this._titleResizeObserver?.disconnect();
1615+
this._titleResizeObserver = null;
15771616
}
15781617

15791618
/* ---- Shadow DOM template ---- */
@@ -1608,7 +1647,31 @@ class PodcastFooter extends HTMLElement {
16081647
}
16091648
.title {
16101649
font-weight: 600; font-size: .85rem;
1611-
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1650+
overflow: hidden;
1651+
}
1652+
.title-text {
1653+
display: inline-block;
1654+
white-space: nowrap;
1655+
padding-right: 1.5em;
1656+
}
1657+
.title[data-overflow] .title-text {
1658+
animation: marquee var(--marquee-duration, 10s) linear infinite alternate;
1659+
will-change: transform;
1660+
}
1661+
.title:hover .title-text {
1662+
animation-play-state: paused;
1663+
}
1664+
@keyframes marquee {
1665+
0%, 12% { transform: translateX(0); }
1666+
88%, 100% { transform: translateX(calc(-1 * var(--marquee-distance, 0px))); }
1667+
}
1668+
@media (prefers-reduced-motion: reduce) {
1669+
.title[data-overflow] .title-text {
1670+
animation: none;
1671+
overflow: hidden;
1672+
text-overflow: ellipsis;
1673+
max-width: 100%;
1674+
}
16121675
}
16131676
.source { font-size: .7rem; color: var(--podcast-player-text-muted, #888);
16141677
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -1693,7 +1756,7 @@ class PodcastFooter extends HTMLElement {
16931756
<div class="footer" part="footer">
16941757
<img class="cover" part="cover" src="" alt="" hidden>
16951758
<div class="info">
1696-
<div class="title" part="title"></div>
1759+
<div class="title" part="title"><span class="title-text"></span></div>
16971760
<div class="source" part="source"></div>
16981761
</div>
16991762
<div class="controls">
@@ -1737,6 +1800,7 @@ class PodcastFooter extends HTMLElement {
17371800
footer: this._shadow.querySelector(".footer"),
17381801
cover: this._shadow.querySelector(".cover"),
17391802
title: this._shadow.querySelector(".title"),
1803+
titleText: this._shadow.querySelector(".title-text"),
17401804
source: this._shadow.querySelector(".source"),
17411805
playBtn: this._shadow.querySelector(".btn-play"),
17421806
skipBack: this._shadow.querySelector(".btn-skip-back"),
@@ -1800,7 +1864,7 @@ class PodcastFooter extends HTMLElement {
18001864
}
18011865

18021866
// New source — load and play
1803-
this._els.title.textContent = title || this._t("player_unknown_episode");
1867+
this._els.titleText.textContent = title || this._t("player_unknown_episode");
18041868
this._els.source.textContent = src.replace(/^https?:\/\//, "").split("/")[0] || src;
18051869

18061870
if (poster) {
@@ -1818,6 +1882,7 @@ class PodcastFooter extends HTMLElement {
18181882
this._audio.volume = parseFloat(this._els.volume.value);
18191883

18201884
this.setAttribute("active", "");
1885+
this._setupTitleMarquee();
18211886
}
18221887

18231888
/** React to pause events from inline <podcast-player> elements.
@@ -2086,7 +2151,7 @@ class PodcastFooter extends HTMLElement {
20862151
this._els.volume.value = this._audio.muted ? 0 : this._audio.volume;
20872152
this._setMuteIcon();
20882153
this._els.rateBtn.textContent = (state.playbackRate || 1) + "×";
2089-
this._els.title.textContent = state.title || this._t("player_unknown_episode");
2154+
this._els.titleText.textContent = state.title || this._t("player_unknown_episode");
20902155
this._els.source.textContent = state.src.replace(/^https?:\/\//, "").split("/")[0] || state.src;
20912156

20922157
if (state.poster) {
@@ -2107,6 +2172,7 @@ class PodcastFooter extends HTMLElement {
21072172

21082173
// Show the footer
21092174
this.setAttribute("active", "");
2175+
this._setupTitleMarquee();
21102176
} catch (_) {}
21112177
}
21122178

exampleSite/content/docs/homepage-setup.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,13 @@ body.dark podcast-player {
115115
--podcast-player-bg: #1e1e2e;
116116
--podcast-player-text: #e0e0e0;
117117
}
118-
```
118+
119+
## Scrolling Long Titles
120+
121+
When a playing episode has a title longer than the footer's title area, the
122+
title automatically scrolls horizontally so the full text is visible. Short
123+
titles render normally with no animation. The behavior respects
124+
`prefers-reduced-motion: reduce`, which falls back to ellipsis truncation.
119125

120126
## Example: Full baseof.html Footer Section
121127

exampleSite/content/es/docs/homepage-setup.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,14 @@ body.dark podcast-player {
8686
--podcast-player-bg: #1e1e2e;
8787
--podcast-player-text: #e0e0e0;
8888
}
89-
```
89+
90+
## Títulos Desplazables
91+
92+
Cuando un episodio en reproducción tiene un título más largo que el área de
93+
título del pie, el título se desplaza horizontalmente de forma automática para
94+
mostrar todo el texto. Los títulos cortos se renderizan normalmente sin
95+
animación. El comportamiento respeta `prefers-reduced-motion: reduce`, que
96+
cae de vuelta a truncamiento con elipsis.
9097
9198
## Ejemplo: Sección de Pie en baseof.html Completa
9299

tests/e2e/player.spec.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,186 @@ test.describe("Podcast Player E2E", () => {
237237
});
238238
});
239239

240+
test.describe("Footer title scrolling", () => {
241+
/** Synthesize a podcast-play event that activates the footer with the
242+
* given title. Used to drive the marquee code without depending on a
243+
* specific example post having a long title. */
244+
const synthesizePlay = async (page, title) => {
245+
await page.evaluate((t) => {
246+
const footer = document.querySelector("podcast-footer");
247+
if (footer) footer.setAttribute("active", "");
248+
document.dispatchEvent(new CustomEvent("podcast-play", {
249+
detail: {
250+
src: "https://example.com/test.mp3",
251+
title: t,
252+
poster: "",
253+
},
254+
}));
255+
}, title);
256+
// Give the footer's requestAnimationFrame a chance to run.
257+
await page.waitForTimeout(150);
258+
};
259+
260+
test("footer shadow DOM contains a .title-text span inside .title", async ({ page }) => {
261+
await page.goto("posts/test-episode/");
262+
const footer = page.locator("podcast-footer");
263+
await expect(footer).toBeAttached();
264+
265+
const hasInner = await footer.evaluate((el) => {
266+
const title = el.shadowRoot?.querySelector(".title");
267+
const inner = el.shadowRoot?.querySelector(".title-text");
268+
return !!title && !!inner && title.contains(inner);
269+
});
270+
expect(hasInner).toBe(true);
271+
});
272+
273+
test("short title does not set data-overflow on the footer title", async ({ page }) => {
274+
await page.goto("posts/test-episode/");
275+
const footer = page.locator("podcast-footer");
276+
await expect(footer).toBeAttached();
277+
278+
// Use a known short title — the footer's available width is narrow
279+
// enough that even the example post's "Episode 42: Hello World"
280+
// overflows. Synthesizing the event with a deterministic short
281+
// string makes the assertion independent of the post content.
282+
await synthesizePlay(page, "Hi");
283+
284+
const result = await footer.evaluate((el) => {
285+
const title = el.shadowRoot?.querySelector(".title");
286+
return {
287+
hasOverflow: title?.hasAttribute("data-overflow") ?? false,
288+
distance: title?.style.getPropertyValue("--marquee-distance") ?? "",
289+
};
290+
});
291+
expect(result.hasOverflow).toBe(false);
292+
expect(result.distance).toBe("");
293+
});
294+
295+
test("long title sets data-overflow, CSS vars, and runs the marquee animation", async ({ page }) => {
296+
await page.goto("posts/test-episode/");
297+
const footer = page.locator("podcast-footer");
298+
await expect(footer).toBeAttached();
299+
300+
// Synthesize a long title to force overflow.
301+
const longTitle = "A".repeat(500) + " — this is a very long episode title to force overflow";
302+
await synthesizePlay(page, longTitle);
303+
304+
const result = await footer.evaluate((el) => {
305+
const title = el.shadowRoot?.querySelector(".title");
306+
const titleText = el.shadowRoot?.querySelector(".title-text");
307+
if (!title || !titleText) return null;
308+
const cs = getComputedStyle(titleText);
309+
return {
310+
hasOverflow: title.hasAttribute("data-overflow"),
311+
distance: title.style.getPropertyValue("--marquee-distance"),
312+
duration: title.style.getPropertyValue("--marquee-duration"),
313+
animationName: cs.animationName,
314+
animationPlayState: cs.animationPlayState,
315+
};
316+
});
317+
318+
expect(result).not.toBeNull();
319+
expect(result.hasOverflow).toBe(true);
320+
expect(result.distance).not.toBe("");
321+
expect(result.duration).not.toBe("");
322+
expect(result.animationName).toBe("marquee");
323+
expect(result.animationPlayState).toBe("running");
324+
});
325+
326+
test("prefers-reduced-motion: reduce disables the marquee animation", async ({ page }) => {
327+
// Opt into reduced motion before the page loads so the CSS media
328+
// query resolves to "reduce" for the duration of the test.
329+
await page.emulateMedia({ reducedMotion: "reduce" });
330+
331+
await page.goto("posts/test-episode/");
332+
const footer = page.locator("podcast-footer");
333+
await expect(footer).toBeAttached();
334+
335+
const longTitle = "A".repeat(500) + " — long title under reduced motion";
336+
await synthesizePlay(page, longTitle);
337+
338+
const result = await footer.evaluate((el) => {
339+
const title = el.shadowRoot?.querySelector(".title");
340+
const titleText = el.shadowRoot?.querySelector(".title-text");
341+
if (!title || !titleText) return null;
342+
const cs = getComputedStyle(titleText);
343+
return {
344+
hasOverflow: title.hasAttribute("data-overflow"),
345+
animationName: cs.animationName,
346+
};
347+
});
348+
349+
expect(result).not.toBeNull();
350+
// Detection still runs — the attribute is set.
351+
expect(result.hasOverflow).toBe(true);
352+
// But the animation is suppressed.
353+
expect(result.animationName).toBe("none");
354+
});
355+
356+
test("marquee state survives a page navigation (Turbolinks permanent)", async ({ page }) => {
357+
await page.goto("posts/test-episode/");
358+
const footer = page.locator("podcast-footer");
359+
await expect(footer).toBeAttached();
360+
361+
const longTitle = "A".repeat(500) + " — long title that must survive navigation";
362+
await synthesizePlay(page, longTitle);
363+
364+
// Sanity: the marquee is active before navigation.
365+
const before = await footer.evaluate((el) => {
366+
const title = el.shadowRoot?.querySelector(".title");
367+
return {
368+
hasOverflow: title?.hasAttribute("data-overflow") ?? false,
369+
distance: title?.style.getPropertyValue("--marquee-distance") ?? "",
370+
};
371+
});
372+
expect(before.hasOverflow).toBe(true);
373+
expect(before.distance).not.toBe("");
374+
375+
// Navigate via a header link (Turbolinks).
376+
await page.locator('header nav a', { hasText: 'Programs' }).click();
377+
await page.waitForTimeout(1000);
378+
379+
// Footer is preserved (data-turbolinks-permanent) and still active.
380+
await expect(footer).toHaveAttribute("active", "");
381+
const after = await footer.evaluate((el) => {
382+
const title = el.shadowRoot?.querySelector(".title");
383+
return {
384+
hasOverflow: title?.hasAttribute("data-overflow") ?? false,
385+
distance: title?.style.getPropertyValue("--marquee-distance") ?? "",
386+
};
387+
});
388+
expect(after.hasOverflow).toBe(true);
389+
expect(after.distance).not.toBe("");
390+
});
391+
392+
test("hovering the title pauses the marquee animation", async ({ page }) => {
393+
await page.goto("posts/test-episode/");
394+
const footer = page.locator("podcast-footer");
395+
await expect(footer).toBeAttached();
396+
397+
const longTitle = "A".repeat(500) + " — long title for hover pause test";
398+
await synthesizePlay(page, longTitle);
399+
400+
// Sanity: the animation is running before hover.
401+
const before = await footer.evaluate((el) => {
402+
const titleText = el.shadowRoot?.querySelector(".title-text");
403+
return getComputedStyle(titleText).animationPlayState;
404+
});
405+
expect(before).toBe("running");
406+
407+
// Hover the title element (the part="title" exposes the parent .title
408+
// so the :hover rule from the shadow stylesheet applies).
409+
await footer.locator("[part='title']").hover();
410+
411+
// The :hover rule sets animation-play-state: paused.
412+
const after = await footer.evaluate((el) => {
413+
const titleText = el.shadowRoot?.querySelector(".title-text");
414+
return getComputedStyle(titleText).animationPlayState;
415+
});
416+
expect(after).toBe("paused");
417+
});
418+
});
419+
240420
test.describe("Mobile Responsive", () => {
241421
test("time-duration stays inside player bounds on narrow viewports", async ({ page }) => {
242422
// Test with a narrow mobile viewport (iPhone SE width)

0 commit comments

Comments
 (0)