Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `url` shortcode parameter on `podcast-player` controls the footer
source link: a URL makes the label clickable, `"none"` hides it,
omitted auto-derives from the audio source (#64).
- `size` attribute on `<podcast-footer>` controls the bar's visual
footprint: `"small"` (default) keeps the compact layout, `"medium"`
widens the bar with larger cover and controls, and `"large"` reserves
the most room for long titles; all three collapse to the compact
layout on narrow viewports (#62).

## [1.3.0] - 2026-06-16

Expand Down
85 changes: 71 additions & 14 deletions assets/js/podcast-player.js
Original file line number Diff line number Diff line change
Expand Up @@ -1711,7 +1711,7 @@ class PodcastFooter extends HTMLElement {
* override applies whether the attribute is present at parse time or
* changed at runtime via JavaScript. */
static get observedAttributes() {
return ["url"];
return ["url", "size"];
}

attributeChangedCallback(name, oldValue, newValue) {
Expand All @@ -1727,6 +1727,10 @@ class PodcastFooter extends HTMLElement {
this._setSourceLink(newValue || "");
}
}
} else if (name === "size") {
// No imperative work needed; the visual change is driven entirely by
// the :host([size="..."]) CSS selectors in the shadow style block.
// Unknown values are ignored by CSS, falling back to the default.
}
}

Expand All @@ -1737,31 +1741,67 @@ class PodcastFooter extends HTMLElement {
<style>
:host { display: none; }
:host([active]) { display: block; }
/* Size variants — :host([size="..."]) declares the footprint variables.
The base rules below consume these via var() with the original
hardcoded value as the fallback, so omitting the attribute (default)
produces exactly today's layout. */
:host([size="medium"]) {
--podcast-footer-cover-size: 48px;
--podcast-footer-info-max-width: 240px;
--podcast-footer-btn-size: 36px;
--podcast-footer-btn-play-size: 44px;
--podcast-footer-skip-btn-size: 32px;
--podcast-footer-progress-min: 120px;
--podcast-footer-vol-width: 90px;
--podcast-footer-padding: 8px 16px;
--podcast-footer-min-height: 60px;
--podcast-footer-gap: 10px;
--podcast-footer-title-font: .95rem;
--podcast-footer-source-font: .8rem;
--podcast-footer-time-font: .85rem;
}
:host([size="large"]) {
--podcast-footer-cover-size: 64px;
--podcast-footer-info-max-width: 400px;
--podcast-footer-btn-size: 44px;
--podcast-footer-btn-play-size: 56px;
--podcast-footer-skip-btn-size: 36px;
--podcast-footer-progress-min: 180px;
--podcast-footer-vol-width: 110px;
--podcast-footer-padding: 12px 24px;
--podcast-footer-min-height: 72px;
--podcast-footer-gap: 12px;
--podcast-footer-title-font: 1.1rem;
--podcast-footer-source-font: .9rem;
--podcast-footer-time-font: 1rem;
}
.footer {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
background: var(--podcast-player-bg, #1e1e2e);
border-top: 1px solid var(--podcast-player-border, rgba(255,255,255,.06));
padding: 6px 12px;
display: flex; align-items: center; gap: 8px;
padding: var(--podcast-footer-padding, 6px 12px);
display: flex; align-items: center; gap: var(--podcast-footer-gap, 8px);
font-family: system-ui, -apple-system, sans-serif;
font-size: .85rem;
color: var(--podcast-player-text, #e0e0e0);
box-shadow: 0 -2px 12px rgba(0,0,0,.3);
min-height: 52px;
min-height: var(--podcast-footer-min-height, 52px);
box-sizing: border-box;
justify-content: center;
}
.cover {
width: 36px; height: 36px; border-radius: 4px;
width: var(--podcast-footer-cover-size, 36px);
height: var(--podcast-footer-cover-size, 36px);
border-radius: 4px;
object-fit: cover; flex-shrink: 0; background: var(--podcast-player-surface, #2a2a3e);
}
.cover[hidden] { display: none; }
.info {
flex: 0 0 auto; max-width: 140px; min-width: 0;
flex: 0 0 auto; max-width: var(--podcast-footer-info-max-width, 140px); min-width: 0;
display: flex; flex-direction: column; gap: 2px;
}
.title {
font-weight: 600; font-size: .85rem;
font-weight: 600; font-size: var(--podcast-footer-title-font, .85rem);
overflow: hidden;
}
.title-text {
Expand All @@ -1788,7 +1828,7 @@ class PodcastFooter extends HTMLElement {
max-width: 100%;
}
}
.source { font-size: .7rem; color: var(--podcast-player-text-muted, #888);
.source { font-size: var(--podcast-footer-source-font, .7rem); color: var(--podcast-player-text-muted, #888);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-decoration: none; cursor: pointer; }
.source:hover { color: var(--podcast-player-text, #e0e0e0);
Expand All @@ -1799,20 +1839,20 @@ class PodcastFooter extends HTMLElement {
.controls { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.btn {
background: transparent; border: none; color: var(--podcast-player-text, #e0e0e0);
width: 32px; height: 32px; border-radius: 50%;
width: var(--podcast-footer-btn-size, 32px); height: var(--podcast-footer-btn-size, 32px); border-radius: 50%;
cursor: pointer; display: inline-flex; align-items: center;
justify-content: center;
transition: background .15s;
}
.btn:hover { background: var(--podcast-player-surface, #2a2a3e); }
.btn-play { width: 36px; height: 36px;
.btn-play { width: var(--podcast-footer-btn-play-size, 36px); height: var(--podcast-footer-btn-play-size, 36px);
background: var(--podcast-player-primary, #6366f1); color: #fff; }
.btn-play:hover { background: var(--podcast-player-accent, #a78bfa); }
.btn-skip-back,
.btn-skip-fwd { width: 28px; height: 28px; }
.btn-skip-fwd { width: var(--podcast-footer-skip-btn-size, 28px); height: var(--podcast-footer-skip-btn-size, 28px); }
.btn-skip-back svg,
.btn-skip-fwd svg { display: block; }
.progress-wrap { flex: 1; min-width: 80px; max-width: 400px; }
.progress-wrap { flex: 1; min-width: var(--podcast-footer-progress-min, 80px); max-width: 400px; }
input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 4px; border-radius: 2px;
Expand All @@ -1832,11 +1872,11 @@ class PodcastFooter extends HTMLElement {
border: 2px solid var(--podcast-player-bg, #1e1e2e);
cursor: pointer;
}
.time { font-size: .75rem; color: var(--podcast-player-text-muted, #888);
.time { font-size: var(--podcast-footer-time-font, .75rem); color: var(--podcast-player-text-muted, #888);
font-variant-numeric: tabular-nums; white-space: nowrap; flex-shrink: 0; }
.time-sep { color: var(--podcast-player-text-muted, #888); font-size: .75rem;
flex-shrink: 0; }
.vol-wrap { display: flex; align-items: center; gap: 2px; width: 70px; flex-shrink: 0; }
.vol-wrap { display: flex; align-items: center; gap: 2px; width: var(--podcast-footer-vol-width, 70px); flex-shrink: 0; }
.vol-wrap[hidden] { display: none; }
.mute-btn { width: 28px; height: 28px; border-radius: 50%; }
.mute-btn svg { display: block; }
Expand All @@ -1858,6 +1898,23 @@ class PodcastFooter extends HTMLElement {
.close svg { display: block; }

@media (max-width: 768px) {
/* All size variants collapse to a compact layout on narrow viewports. */
:host([size="medium"]),
:host([size="large"]) {
--podcast-footer-cover-size: 28px;
--podcast-footer-info-max-width: 100px;
--podcast-footer-btn-size: 32px;
--podcast-footer-btn-play-size: 36px;
--podcast-footer-skip-btn-size: 28px;
--podcast-footer-progress-min: 60px;
--podcast-footer-vol-width: 50px;
--podcast-footer-padding: 4px 8px;
--podcast-footer-min-height: 48px;
--podcast-footer-gap: 6px;
--podcast-footer-title-font: .8rem;
--podcast-footer-source-font: .7rem;
--podcast-footer-time-font: .7rem;
}
.footer { gap: 6px; padding: 4px 8px; font-size: .8rem; min-height: 48px; }
.cover { width: 28px; height: 28px; }
.info { max-width: 100px; }
Expand Down
16 changes: 16 additions & 0 deletions exampleSite/content/docs/homepage-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ The persistent footer shows a small source label (the audio host's domain) below

The same attribute can be set on the top-level `<podcast-footer url="...">` to override whatever the inline player sends. URLs are sanitized at build time and at runtime; only `http` and `https` schemes are accepted when the link is rendered.

## Footer Size

The `size` attribute on `<podcast-footer>` controls the bar's visual footprint. Three presets are available:

- `size="small"` (default): the current compact bar. 36px cover, 140px info column, 32px buttons. Omitting the `size` attribute is equivalent to `size="small"`.
- `size="medium"`: a wider bar with a 48px cover, 240px info column, 36px buttons, and slightly larger fonts.
- `size="large"`: a full-bleed banner with a 64px cover, 400px info column, 44-56px buttons, and the largest fonts.

```html
<podcast-footer id="podcast-footer" size="medium" data-turbo-permanent></podcast-footer>
```

On viewports narrower than 768px, all three sizes collapse to the same compact layout that `size="small"` produces on desktop. Unknown values (`size="huge"`, etc.) fall back to the default `small` layout.

The attribute is purely visual. Persistence, playback, and the audio source URL are unaffected by `size`. Theme variables (`--podcast-player-bg`, `--podcast-player-text`, etc.) continue to work as before.

## Example: Full baseof.html Footer Section

```html
Expand Down
16 changes: 16 additions & 0 deletions exampleSite/content/es/docs/homepage-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,22 @@ El pie persistente muestra una pequeña etiqueta de fuente (el dominio del servi

El mismo atributo puede establecerse en el `<podcast-footer url="...">` de nivel superior para sobrescribir lo que envíe el reproductor en línea. Las URL se sanitizan en tiempo de compilación y de ejecución; solo se aceptan los esquemas `http` y `https` al renderizar el enlace.

## Tamaño del Pie

El atributo `size` en `<podcast-footer>` controla el tamaño visual de la barra. Hay tres preajustes disponibles:

- `size="small"` (por defecto): la barra compacta actual. Portada de 36px, columna de info de 140px, botones de 32px. Omitir el atributo `size` es equivalente a `size="small"`.
- `size="medium"`: una barra más ancha con portada de 48px, columna de info de 240px, botones de 36px y fuentes ligeramente más grandes.
- `size="large"`: un banner de ancho completo con portada de 64px, columna de info de 400px, botones de 44-56px y las fuentes más grandes.

```html
<podcast-footer id="podcast-footer" size="medium" data-turbo-permanent></podcast-footer>
```

En pantallas de menos de 768px de ancho, los tres tamaños colapsan al mismo diseño compacto que `size="small"` produce en escritorio. Los valores desconocidos (`size="huge"`, etc.) vuelven al diseño `small` por defecto.

El atributo es puramente visual. La persistencia, la reproducción y la URL de la fuente de audio no se ven afectadas por `size`. Las variables de tema (`--podcast-player-bg`, `--podcast-player-text`, etc.) siguen funcionando como antes.

## Ejemplo: Sección de Pie en baseof.html Completa

```
Expand Down
2 changes: 1 addition & 1 deletion exampleSite/layouts/_default/baseof.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
</div>
</footer>

<podcast-footer id="podcast-footer" data-turbolinks-permanent data-turbo-permanent hx-preserve></podcast-footer>
<podcast-footer id="podcast-footer" size="medium" data-turbolinks-permanent data-turbo-permanent hx-preserve></podcast-footer>

<script nonce="{{ $cspNonce }}">
(function() {
Expand Down
143 changes: 143 additions & 0 deletions tests/e2e/player.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -632,4 +632,147 @@ test.describe("Podcast Player E2E", () => {
expect(result.href).toBe(expected);
});
});

test.describe("Footer size attribute", () => {
/** Synthesize a podcast-play event that activates the footer. Used
* to drive the cover-sizing code path without depending on the
* audio network. */
const synthesizePlay = async (page) => {
await page.evaluate(() => {
const footer = document.querySelector("podcast-footer");
if (footer) footer.setAttribute("active", "");
document.dispatchEvent(new CustomEvent("podcast-play", {
detail: {
src: "https://example.com/test.mp3",
title: "Size Test",
poster: "",
currentTime: 0,
},
}));
});
await page.waitForTimeout(150);
};

/** Read the rendered width (in px) of the footer's cover element.
* Returns NaN if the cover can't be found. Uses page.evaluate
* (not locator.evaluate) to avoid Playwright's auto-wait on
* visibility — the footer host starts with `display: none` until
* the `active` attribute is set, and locator.evaluate can hang
* waiting for it to become "actionable" even when the shadow DOM
* is fully laid out. */
const getCoverWidthPx = async (page) => {
const w = await page.evaluate(() => {
const footer = document.querySelector("podcast-footer");
const cover = footer?.shadowRoot?.querySelector(".cover");
if (!cover) return null;
// The CSS rule sets width as a length (e.g. "48px"). Strip the
// unit before returning a number.
return getComputedStyle(cover).width;
});
return w ? parseFloat(w) : NaN;
};

test("a. example site footer has size='medium' on every page", async ({ page }) => {
await page.goto("posts/test-episode/");
const footer = page.locator("podcast-footer");
await expect(footer).toBeAttached();
await expect(footer).toHaveAttribute("size", "medium");
});

test("b. with size='medium', the cover is larger than the 36px default", async ({ page }) => {
await page.goto("posts/test-episode/");
const footer = page.locator("podcast-footer");
await expect(footer).toBeAttached();

// Activate the footer so its shadow DOM is fully laid out.
await synthesizePlay(page);

const widthPx = await getCoverWidthPx(page);
expect(widthPx).toBeGreaterThan(36);
});

test("c. setting size='small' at runtime shrinks the cover back to 36px", async ({ page }) => {
await page.goto("posts/test-episode/");
const footer = page.locator("podcast-footer");
await expect(footer).toBeAttached();

// Sanity: the example footer starts in "medium" mode.
await expect(footer).toHaveAttribute("size", "medium");

// Activate the footer (the example site has size='medium', so the
// cover should be > 36px here).
await synthesizePlay(page);
const mediumWidth = await getCoverWidthPx(page);
expect(mediumWidth).toBeGreaterThan(36);

// Override the size at runtime. There's no :host([size='small'])
// rule, so the CSS var falls back to the original hardcoded value
// of 36px.
await footer.evaluate((el) => el.setAttribute("size", "small"));
await page.waitForTimeout(150);

const smallWidth = await getCoverWidthPx(page);
expect(smallWidth).toBe(36);
});

test("d. setting size='large' makes the cover even bigger than medium", async ({ page }) => {
await page.goto("posts/test-episode/");
const footer = page.locator("podcast-footer");
await expect(footer).toBeAttached();

await synthesizePlay(page);
const mediumWidth = await getCoverWidthPx(page);
expect(mediumWidth).toBeGreaterThan(36);

// Flip to large — the :host([size='large']) rule sets the cover
// to 64px.
await footer.evaluate((el) => el.setAttribute("size", "large"));
await page.waitForTimeout(150);

const largeWidth = await getCoverWidthPx(page);
expect(largeWidth).toBeGreaterThan(mediumWidth);
expect(largeWidth).toBe(64);
});

test("e. on a narrow viewport, all size variants collapse to the compact layout", async ({ page }) => {
// Mobile viewport (iPhone SE width) — narrower than the 768px
// breakpoint that the size media query targets.
await page.setViewportSize({ width: 375, height: 812 });

await page.goto("posts/test-episode/");
const footer = page.locator("podcast-footer");
await expect(footer).toBeAttached();

// Force the largest variant — on desktop this would be 64px, but
// the @media (max-width: 768px) override collapses it to 28px.
await footer.evaluate((el) => el.setAttribute("size", "large"));
await synthesizePlay(page);
await page.waitForTimeout(150);

const widthPx = await getCoverWidthPx(page);
// The mobile media query sets --podcast-footer-cover-size: 28px,
// so the rendered cover must be 28px — NOT 64px.
expect(widthPx).toBe(28);
});

test("f. the size attribute survives a page navigation (Turbolinks permanent)", async ({ page }) => {
await page.goto("posts/test-episode/");
const footer = page.locator("podcast-footer");
await expect(footer).toBeAttached();

// Activate and override the size at runtime.
await synthesizePlay(page);
await footer.evaluate((el) => el.setAttribute("size", "large"));
await expect(footer).toHaveAttribute("size", "large");

// Navigate via a header link (Turbolinks).
await page.locator('header nav a', { hasText: 'Programs' }).click();
await page.waitForTimeout(1000);

// The footer is preserved (data-turbolinks-permanent) and the
// size attribute survives with the element.
await expect(footer).toHaveAttribute("active", "");
await expect(footer).toHaveAttribute("size", "large");
});
});
});
Loading