Skip to content

Commit 906ff8d

Browse files
authored
feat: control footer URL via shortcode url param (#64) (#67)
The persistent footer's source label is upgraded from plain text to a clickable link. A new url shortcode parameter on podcast-player controls the link: an explicit URL overrides the auto-derived link, "none" hides the link, and omitting it auto-derives the link from the audio src by stripping the filename. The same attribute can be set on the top-level <podcast-footer url="..."> to override what the inline player sends. URLs are sanitized at build time (http/https/relative/fragment/dot-relative, plus "none") and at runtime (http/https only). Co-authored-by: Adur <adurrr@users.noreply.github.com>
1 parent 3e4c4f2 commit 906ff8d

42 files changed

Lines changed: 2382 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2020
active (helps diagnose "footer disappears on navigation" issue).
2121
- Footer player horizontally scrolls long episode titles when they overflow,
2222
with a `prefers-reduced-motion: reduce` fallback to ellipsis (#63).
23+
- `url` shortcode parameter on `podcast-player` controls the footer
24+
source link: a URL makes the label clickable, `"none"` hides it,
25+
omitted auto-derives from the audio source (#64).
2326

2427
## [1.3.0] - 2026-06-16
2528

assets/js/podcast-player.js

Lines changed: 140 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ class PodcastPlayer extends HTMLElement {
157157
/** Attributes the component should react to. */
158158
static get observedAttributes() {
159159
return ["src", "title", "poster", "chapters", "type", "autoplay",
160-
"data-preload", "persistent", "data-source"];
160+
"data-preload", "persistent", "data-source", "url"];
161161
}
162162

163163
/** Prefix for sessionStorage state key (instance ID appended). */
@@ -906,7 +906,7 @@ class PodcastPlayer extends HTMLElement {
906906
src: this._audio.src || this.getAttribute("src") || "",
907907
title: this.getAttribute("title") || "",
908908
poster: this.getAttribute("poster") || "",
909-
url: this._audio.src || "",
909+
url: this._resolveUrl(),
910910
currentTime: this._audio.currentTime,
911911
},
912912
}));
@@ -1329,6 +1329,40 @@ class PodcastPlayer extends HTMLElement {
13291329
return `${m}:${pad(s)}`;
13301330
}
13311331

1332+
/**
1333+
* Resolve the URL to use for the footer's source link.
1334+
* Priority: explicit `url` attribute (or "none" sentinel) > auto-derive from src.
1335+
* Auto-derivation strips the filename from the audio src.
1336+
* Returns:
1337+
* - "none" when the user explicitly opted out
1338+
* - an absolute http(s) URL string when a usable link can be determined
1339+
* - "" when no usable URL can be determined (caller should hide the link)
1340+
*/
1341+
_resolveUrl() {
1342+
const explicit = (this.getAttribute("url") || "").trim();
1343+
if (explicit.toLowerCase() === "none") return "none";
1344+
if (explicit) {
1345+
// Resolve relative URLs against the document base.
1346+
try {
1347+
return new URL(explicit, document.baseURI).href;
1348+
} catch (_) {
1349+
return "";
1350+
}
1351+
}
1352+
// Auto-derive from audio src.
1353+
const src = this._audio.src || this.getAttribute("src") || "";
1354+
if (!src) return "";
1355+
try {
1356+
const u = new URL(src, document.baseURI);
1357+
if (u.protocol !== "http:" && u.protocol !== "https:") return "";
1358+
// Strip filename, keep path up to last "/"
1359+
const lastSlash = u.href.lastIndexOf("/");
1360+
return lastSlash >= 0 ? u.href.substring(0, lastSlash + 1) : u.href;
1361+
} catch (_) {
1362+
return "";
1363+
}
1364+
}
1365+
13321366
/** Highlight the currently playing chapter. */
13331367
_updateActiveChapter() {
13341368
const t = this._audio.currentTime;
@@ -1472,6 +1506,7 @@ class PodcastFooter extends HTMLElement {
14721506
player_no_source: "No audio source available",
14731507
player_close: "Close player",
14741508
player_unknown_episode: "Unknown Episode",
1509+
player_source_link: "View episode",
14751510
};
14761511
}
14771512

@@ -1585,6 +1620,61 @@ class PodcastFooter extends HTMLElement {
15851620
}
15861621
}
15871622

1623+
/**
1624+
* Apply a URL to the footer's source link element, or hide it.
1625+
* url === "none" → hide the link entirely
1626+
* url is empty/invalid → hide the link (and clear href)
1627+
* url is a valid http(s) URL → set the href, make the link visible
1628+
* The element is a real `<a>`, not a `<div>`, so the href is set directly.
1629+
* Sanitization: only http(s) protocols are accepted. Anything else
1630+
* (javascript:, data:, ftp:, malformed) results in the link being hidden.
1631+
* The most recently accepted URL is cached on `this._currentSourceUrl`
1632+
* so persistence (save/restore) round-trips the same value the user sees.
1633+
*
1634+
* Note: the link is only revealed if it already has text content. This
1635+
* prevents a top-level `<podcast-footer url="...">` attribute from
1636+
* flashing an empty link before any inline player has dispatched a
1637+
* podcast-play event.
1638+
*/
1639+
_setSourceLink(url) {
1640+
const link = this._els.source;
1641+
if (!link) return;
1642+
1643+
if (typeof url === "string" && url.toLowerCase() === "none") {
1644+
link.hidden = true;
1645+
link.removeAttribute("href");
1646+
this._currentSourceUrl = "";
1647+
return;
1648+
}
1649+
if (!url) {
1650+
link.hidden = true;
1651+
link.removeAttribute("href");
1652+
this._currentSourceUrl = "";
1653+
return;
1654+
}
1655+
try {
1656+
const u = new URL(url, document.baseURI);
1657+
if (u.protocol !== "http:" && u.protocol !== "https:") {
1658+
link.hidden = true;
1659+
link.removeAttribute("href");
1660+
this._currentSourceUrl = "";
1661+
return;
1662+
}
1663+
link.href = u.href;
1664+
this._currentSourceUrl = u.href;
1665+
// Only reveal if the link has visible text (set by a podcast-play
1666+
// event). The footer renders no source label before a track starts.
1667+
if (link.textContent && link.textContent.trim()) {
1668+
link.hidden = false;
1669+
link.setAttribute("aria-label", this._t("player_source_link"));
1670+
}
1671+
} catch (_) {
1672+
link.hidden = true;
1673+
link.removeAttribute("href");
1674+
this._currentSourceUrl = "";
1675+
}
1676+
}
1677+
15881678
/** Set the mute button icon based on current muted/volume state. */
15891679
_setMuteIcon() {
15901680
if (this._audio.muted || this._audio.volume === 0) {
@@ -1615,6 +1705,31 @@ class PodcastFooter extends HTMLElement {
16151705
this._titleResizeObserver = null;
16161706
}
16171707

1708+
/** Attributes the component should react to. The `url` attribute lets a
1709+
* page override whatever the inline player sent (e.g. wrap the footer's
1710+
* source label in a clickable link to a specific episode page). The
1711+
* override applies whether the attribute is present at parse time or
1712+
* changed at runtime via JavaScript. */
1713+
static get observedAttributes() {
1714+
return ["url"];
1715+
}
1716+
1717+
attributeChangedCallback(name, oldValue, newValue) {
1718+
if (oldValue === newValue) return;
1719+
if (name === "url") {
1720+
// Cache the override so a later podcast-play event can prefer it
1721+
// over the inline player's URL. If the footer is already showing
1722+
// a source, apply immediately too.
1723+
this._topLevelUrlOverride = (newValue != null) ? newValue : null;
1724+
if (this._els && this._els.source) {
1725+
const link = this._els.source;
1726+
if (link.textContent && link.textContent.trim()) {
1727+
this._setSourceLink(newValue || "");
1728+
}
1729+
}
1730+
}
1731+
}
1732+
16181733
/* ---- Shadow DOM template ---- */
16191734

16201735
_render() {
@@ -1674,7 +1789,13 @@ class PodcastFooter extends HTMLElement {
16741789
}
16751790
}
16761791
.source { font-size: .7rem; color: var(--podcast-player-text-muted, #888);
1677-
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1792+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1793+
text-decoration: none; cursor: pointer; }
1794+
.source:hover { color: var(--podcast-player-text, #e0e0e0);
1795+
text-decoration: underline; }
1796+
.source:focus-visible { outline: 2px solid var(--podcast-player-accent, #7c3aed);
1797+
outline-offset: 2px; }
1798+
.source[hidden] { display: none; }
16781799
.controls { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
16791800
.btn {
16801801
background: transparent; border: none; color: var(--podcast-player-text, #e0e0e0);
@@ -1757,7 +1878,7 @@ class PodcastFooter extends HTMLElement {
17571878
<img class="cover" part="cover" src="" alt="" hidden>
17581879
<div class="info">
17591880
<div class="title" part="title"><span class="title-text"></span></div>
1760-
<div class="source" part="source"></div>
1881+
<a class="source" part="source" hidden></a>
17611882
</div>
17621883
<div class="controls">
17631884
<button class="btn btn-skip-back" part="skip-back-btn"
@@ -1866,6 +1987,13 @@ class PodcastFooter extends HTMLElement {
18661987
// New source — load and play
18671988
this._els.titleText.textContent = title || this._t("player_unknown_episode");
18681989
this._els.source.textContent = src.replace(/^https?:\/\//, "").split("/")[0] || src;
1990+
// Top-level <podcast-footer url="..."> always overrides what the inline
1991+
// player sent via the event detail.
1992+
const eventUrl = (e.detail && e.detail.url) || "";
1993+
const finalUrl = (this._topLevelUrlOverride != null)
1994+
? this._topLevelUrlOverride
1995+
: eventUrl;
1996+
this._setSourceLink(finalUrl);
18691997

18701998
if (poster) {
18711999
this._els.cover.src = poster;
@@ -2120,6 +2248,7 @@ class PodcastFooter extends HTMLElement {
21202248
timestamp: Date.now(),
21212249
title: this._els.title.textContent,
21222250
poster: this._els.cover.getAttribute("src") || "",
2251+
url: this._currentSourceUrl || "",
21232252
};
21242253
sessionStorage.setItem(PodcastFooter.PERSISTENCE_KEY, JSON.stringify(state));
21252254
} catch (_) {}
@@ -2153,6 +2282,13 @@ class PodcastFooter extends HTMLElement {
21532282
this._els.rateBtn.textContent = (state.playbackRate || 1) + "×";
21542283
this._els.titleText.textContent = state.title || this._t("player_unknown_episode");
21552284
this._els.source.textContent = state.src.replace(/^https?:\/\//, "").split("/")[0] || state.src;
2285+
// Top-level <podcast-footer url="..."> override (set in HTML or at
2286+
// runtime) takes precedence over the URL persisted from a prior
2287+
// session.
2288+
const restoreUrl = (this._topLevelUrlOverride != null)
2289+
? this._topLevelUrlOverride
2290+
: (state.url || "");
2291+
this._setSourceLink(restoreUrl);
21562292

21572293
if (state.poster) {
21582294
this._els.cover.src = state.poster;

exampleSite/content/docs/getting-started.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ After installing Wavecast, add a podcast player to any page with a single shortc
4444
|-----------|----------|---------|-------------|
4545
| `src` | **yes** |: | Audio URL or local file path |
4646
| `title` | no | `""` | Episode title in the player header |
47+
| `url` | no | `""` | Link target for the footer source label. Set to a URL (http/https or site-relative) to make the label clickable, or `"none"` to hide the link. Omit to auto-derive from the audio source. |
4748
| `poster` | no | `""` | Cover image URL |
4849
| `description` | no | `""` | Markdown description |
4950
| `type` | no | `"audio/mpeg"` | MIME type |

exampleSite/content/docs/homepage-setup.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,30 @@ title automatically scrolls horizontally so the full text is visible. Short
123123
titles render normally with no animation. The behavior respects
124124
`prefers-reduced-motion: reduce`, which falls back to ellipsis truncation.
125125

126+
## Linking the Footer
127+
128+
The persistent footer shows a small source label (the audio host's domain) below the episode title. By default this label is a link to the audio file's parent directory. The `url` shortcode parameter overrides or hides the link:
129+
130+
- Omitted, auto-derived: the link points at the audio file's parent directory (e.g. `src="https://example.com/episodes/foo.mp3"` makes the link `https://example.com/episodes/`).
131+
- A URL: the link points at that URL instead. Accepts `http://`, `https://`, and site-relative paths starting with `/`, `#`, or `.`.
132+
- `"none"`: the link is hidden entirely. Useful for live radio streams that have no episode page.
133+
134+
```go-html-template
135+
{{</* podcast-player
136+
src="https://example.com/stream.mp3"
137+
title="Live Broadcast"
138+
url="https://example.com/shows/live"
139+
*/>}}
140+
141+
{{</* podcast-player
142+
src="https://example.com/stream.mp3"
143+
title="Live Broadcast"
144+
url="none"
145+
*/>}}
146+
```
147+
148+
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.
149+
126150
## Example: Full baseof.html Footer Section
127151

128152
```html

exampleSite/content/es/docs/getting-started.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Después de instalar Wavecast, añade un reproductor de podcast a cualquier pág
4444
|-----------|----------|---------|-------------|
4545
| `src` | **sí** |: | URL de audio o ruta de archivo local |
4646
| `title` | no | `""` | Título del episodio en el reproductor |
47+
| `url` | no | `""` | Destino del enlace para la etiqueta de fuente del pie. Establece una URL (http/https o relativa al sitio) para hacer la etiqueta clickeable, o `"none"` para ocultar el enlace. Omite para derivar automáticamente desde la fuente de audio. |
4748
| `poster` | no | `""` | URL de la imagen de portada |
4849
| `description` | no | `""` | Descripción en Markdown |
4950
| `type` | no | `"audio/mpeg"` | Tipo MIME |

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,30 @@ mostrar todo el texto. Los títulos cortos se renderizan normalmente sin
9595
animación. El comportamiento respeta `prefers-reduced-motion: reduce`, que
9696
cae de vuelta a truncamiento con elipsis.
9797
98+
## Vincular el Pie
99+
100+
El pie persistente muestra una pequeña etiqueta de fuente (el dominio del servidor de audio) bajo el título del episodio. Por defecto esta etiqueta es un enlace al directorio padre del archivo de audio. El parámetro `url` del shortcode sobrescribe u oculta el enlace:
101+
102+
- Omitido, derivado automáticamente: el enlace apunta al directorio padre del archivo de audio (ej. `src="https://example.com/episodes/foo.mp3"` produce el enlace `https://example.com/episodes/`).
103+
- Una URL: el enlace apunta a esa URL. Acepta `http://`, `https://` y rutas relativas al sitio que empiecen por `/`, `#` o `.`.
104+
- `"none"`: el enlace se oculta por completo. Útil para transmisiones de radio en vivo sin página de episodio.
105+
106+
```go-html-template
107+
{{</* podcast-player
108+
src="https://example.com/stream.mp3"
109+
title="Transmisión en vivo"
110+
url="https://example.com/shows/live"
111+
*/>}}
112+
113+
{{</* podcast-player
114+
src="https://example.com/stream.mp3"
115+
title="Transmisión en vivo"
116+
url="none"
117+
*/>}}
118+
```
119+
120+
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.
121+
98122
## Ejemplo: Sección de Pie en baseof.html Completa
99123

100124
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
title: "URL Hidden Demo"
3+
date: 2026-06-27T10:00:00+03:00
4+
draft: false
5+
---
6+
7+
{{< podcast-player
8+
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-10.mp3"
9+
title="Episode with footer link hidden"
10+
url="none"
11+
>}}
12+
13+
This page demonstrates the new `url="none"` shortcode parameter: the
14+
footer source label is hidden entirely (no link).
15+
16+
<div class="nav-buttons">
17+
<a href="{{< relref "posts/url-override.md" >}}" class="nav-button">Next: Override Demo</a>
18+
<a href="{{< relref "/" >}}" class="nav-button nav-button-primary">Back to Home</a>
19+
</div>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
title: "URL Override Demo"
3+
date: 2026-06-27T10:00:00+03:00
4+
draft: false
5+
---
6+
7+
{{< podcast-player
8+
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-9.mp3"
9+
title="Episode with custom footer link target"
10+
url="https://example.com/episodes/long-title-demo"
11+
>}}
12+
13+
This page demonstrates the new `url` shortcode parameter: the footer
14+
source label is clickable and points at the URL above.
15+
16+
<div class="nav-buttons">
17+
<a href="{{< relref "posts/url-hidden.md" >}}" class="nav-button">Next: Hidden Link Demo</a>
18+
<a href="{{< relref "/" >}}" class="nav-button nav-button-primary">Back to Home</a>
19+
</div>

exampleSite/i18n/es.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ other = "No hay fuente de audio disponible"
5959
[player_unknown_episode]
6060
other = "Episodio desconocido"
6161

62+
[player_source_link]
63+
other = "Ver episodio"
64+
6265
# ── Carousel ──
6366
[carousel_label]
6467
other = "Carrusel de imágenes"

exampleSite/layouts/_default/baseof.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@
185185
"player_seek" "player_cover_alt" "player_cover_of"
186186
"player_episode" "player_close" "player_region"
187187
"player_error" "player_no_source" "player_unknown_episode"
188+
"player_source_link"
188189
"code_copy_title" "code_copy_label" "code_copied" "code_copied_label"
189190
-}}
190191
<script nonce="{{ $cspNonce }}">

0 commit comments

Comments
 (0)