diff --git a/CHANGELOG.md b/CHANGELOG.md index c094c2b..4e4f966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 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). +- `` Web Component adds live radio support to the + persistent footer: real-time now-playing metadata (title, artist, + 24H start/end time) from an AzuraCast `nowplaying` API, a "Listen + Live" CTA in the footer when other audio is playing, a pulsing LIVE + badge that respects `prefers-reduced-motion: reduce`, exponential + backoff on polling errors, and single-stream enforcement with the + existing podcast playback (#65). ## [1.3.0] - 2026-06-16 diff --git a/assets/js/podcast-player.js b/assets/js/podcast-player.js index 86bcb23..4188c76 100644 --- a/assets/js/podcast-player.js +++ b/assets/js/podcast-player.js @@ -44,36 +44,47 @@ export function detectSourceType(url) { * @returns {SourceAdapter} */ export function createSourceAdapter(element) { - const type = element.getAttribute("data-source") || detectSourceType( - element.getAttribute("src") || "", - ); + const type = + element.getAttribute("data-source") || detectSourceType(element.getAttribute("src") || ""); switch (type) { - case "azuracast": return new AzuracastAdapter(element); - case "ivoox": return new IvooxAdapter(element); - default: return new LocalAdapter(element); + case "azuracast": + return new AzuracastAdapter(element); + case "ivoox": + return new IvooxAdapter(element); + default: + return new LocalAdapter(element); } } export class LocalAdapter { - constructor(element) { this.element = element; } + constructor(element) { + this.element = element; + } async resolve() { const src = this.element.getAttribute("src"); if (!src) throw new Error("LocalAdapter: missing src attribute"); return src; } - async enrich() { return null; } + async enrich() { + return null; + } } export class AzuracastAdapter { - constructor(element) { this.element = element; } + constructor(element) { + this.element = element; + } async resolve() { const src = this.element.getAttribute("src"); if (src) return src; - const apiUrl = this.element.getAttribute("azuracast-api-url"); + const apiUrl = + this.element.getAttribute("data-azuracast-api-url") || + this.element.getAttribute("azuracast-api-url"); if (!apiUrl) throw new Error("AzuracastAdapter: missing azuracast-api-url attribute"); // Validate scheme before fetching to prevent client-side SSRF const parsed = new URL(apiUrl); - if (parsed.protocol !== "https:") throw new Error("AzuracastAdapter: azuracast-api-url must use HTTPS"); + if (parsed.protocol !== "https:") + throw new Error("AzuracastAdapter: azuracast-api-url must use HTTPS"); const resp = await fetch(apiUrl); if (!resp.ok) throw new Error(`AzuracastAdapter: API error ${resp.status}`); const data = await resp.json(); @@ -94,7 +105,9 @@ export class AzuracastAdapter { } export class IvooxAdapter { - constructor(element) { this.element = element; } + constructor(element) { + this.element = element; + } async resolve() { const src = this.element.getAttribute("src"); if (!/ivoox\.com/i.test(src)) return src; @@ -105,7 +118,9 @@ export class IvooxAdapter { console.warn("IvooxAdapter: refusing to fetch non-ivoox.com or non-HTTPS URL", src); return src; } - } catch { return src; } + } catch { + return src; + } try { const resp = await fetch(src); if (!resp.ok) return src; @@ -117,10 +132,14 @@ export class IvooxAdapter { if (dataUrl?.getAttribute("data-audio-url")) return dataUrl.getAttribute("data-audio-url"); const audioSrc = doc.querySelector("audio source"); if (audioSrc?.src) return audioSrc.src; - } catch { /* fall through */ } + } catch { + /* fall through */ + } return src; } - async enrich() { return null; } + async enrich() { + return null; + } } /* ---- Open-source SVG icons (Feather/Lucide, MIT licensed) ---- */ @@ -138,9 +157,12 @@ function urlsMatch(a, b) { const uB = new URL(b, document.baseURI); if (uA.pathname === uB.pathname && uA.search === uB.search) return true; // Also match if paths differ only by trailing slash - return uA.pathname.replace(/\/$/, "") === uB.pathname.replace(/\/$/, "") - && uA.search === uB.search; - } catch { return a.endsWith(b) || b.endsWith(a); } + return ( + uA.pathname.replace(/\/$/, "") === uB.pathname.replace(/\/$/, "") && uA.search === uB.search + ); + } catch { + return a.endsWith(b) || b.endsWith(a); + } } const ICON_SKIP_BACK = ''; @@ -156,18 +178,34 @@ const ICON_VOL_MUTED = class PodcastPlayer extends HTMLElement { /** Attributes the component should react to. */ static get observedAttributes() { - return ["src", "title", "poster", "chapters", "type", "autoplay", - "data-preload", "persistent", "data-source", "url"]; + return [ + "src", + "title", + "poster", + "chapters", + "type", + "autoplay", + "data-preload", + "persistent", + "data-source", + "url", + ]; } /** Prefix for sessionStorage state key (instance ID appended). */ - static get PERSISTENCE_KEY() { return "podcastPlayerState"; } + static get PERSISTENCE_KEY() { + return "podcastPlayerState"; + } /** Minimum interval (ms) between throttled sessionStorage writes. */ - static get SAVE_INTERVAL_MS() { return 30000; } + static get SAVE_INTERVAL_MS() { + return 30000; + } /** Staleness threshold (seconds) — discard saved position older than this. */ - static get STATE_TTL_SECONDS() { return 3600; } + static get STATE_TTL_SECONDS() { + return 3600; + } /* ------------------------------------------------------------------ */ /* i18n — default English strings, overridable via window.wavecast */ @@ -193,6 +231,7 @@ class PodcastPlayer extends HTMLElement { player_episode: "Podcast", player_error: "Playback error", player_no_source: "No audio source available", + player_listen_live: "Listen Live", }; } @@ -241,7 +280,7 @@ class PodcastPlayer extends HTMLElement { this._t = (key, vars) => { let s = this._i[key] || key; if (vars) { - Object.keys(vars).forEach(k => { + Object.keys(vars).forEach((k) => { s = s.replace("{" + k + "}", vars[k]); }); } @@ -524,19 +563,19 @@ class PodcastPlayer extends HTMLElement { // Cache DOM refs this._els = { - poster: this._shadow.querySelector(".poster"), - title: this._shadow.querySelector(".title"), - playBtn: this._shadow.querySelector(".btn-play"), - skipBack: this._shadow.querySelector(".btn-skip-back"), - skipFwd: this._shadow.querySelector(".btn-skip-fwd"), - progress: this._shadow.querySelector(".progress"), - timeCurrent: this._shadow.querySelector(".time-current"), + poster: this._shadow.querySelector(".poster"), + title: this._shadow.querySelector(".title"), + playBtn: this._shadow.querySelector(".btn-play"), + skipBack: this._shadow.querySelector(".btn-skip-back"), + skipFwd: this._shadow.querySelector(".btn-skip-fwd"), + progress: this._shadow.querySelector(".progress"), + timeCurrent: this._shadow.querySelector(".time-current"), timeDuration: this._shadow.querySelector(".time-duration"), - volume: this._shadow.querySelector(".volume"), - muteBtn: this._shadow.querySelector(".btn-mute"), - rateBtn: this._shadow.querySelector(".rate-btn"), - chapters: this._shadow.querySelector(".chapters"), - error: this._shadow.querySelector(".error-msg"), + volume: this._shadow.querySelector(".volume"), + muteBtn: this._shadow.querySelector(".btn-mute"), + rateBtn: this._shadow.querySelector(".rate-btn"), + chapters: this._shadow.querySelector(".chapters"), + error: this._shadow.querySelector(".error-msg"), }; } @@ -659,7 +698,9 @@ class PodcastPlayer extends HTMLElement { this._els.poster.src = val; this._els.poster.hidden = false; const title = this.getAttribute("title") || ""; - this._els.poster.alt = title ? this._t("player_cover_of", {title}) : this._t("player_cover_alt"); + this._els.poster.alt = title + ? this._t("player_cover_of", { title }) + : this._t("player_cover_alt"); } else { this._els.poster.src = ""; this._els.poster.hidden = true; @@ -677,9 +718,7 @@ class PodcastPlayer extends HTMLElement { for (const p of parts) { const m = p.match(/^(\d{2}):(\d{2}):(\d{2})-(.+)/); if (!m) continue; - const seconds = parseInt(m[1], 10) * 3600 - + parseInt(m[2], 10) * 60 - + parseInt(m[3], 10); + const seconds = parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10); this._chapters.push({ time: seconds, label: m[4].trim() }); } if (this._chapters.length === 0) { @@ -713,19 +752,21 @@ class PodcastPlayer extends HTMLElement { /** Skip relative seconds (negative = backward). */ _skip(sec) { - this._audio.currentTime = Math.max(0, Math.min( - this._audio.currentTime + sec, - this._audio.duration || 0, - )); + this._audio.currentTime = Math.max( + 0, + Math.min(this._audio.currentTime + sec, this._audio.duration || 0), + ); // Notify footer and other inline players of position change - this.dispatchEvent(new CustomEvent("podcast-seek", { - bubbles: true, - composed: true, - detail: { - currentTime: this._audio.currentTime, - src: this._audio.src || this.getAttribute("src") || "", - }, - })); + this.dispatchEvent( + new CustomEvent("podcast-seek", { + bubbles: true, + composed: true, + detail: { + currentTime: this._audio.currentTime, + src: this._audio.src || this.getAttribute("src") || "", + }, + }), + ); } /** Seek to the position set on the progress slider. */ @@ -734,14 +775,16 @@ class PodcastPlayer extends HTMLElement { const pct = parseFloat(this._els.progress.value) / 100; this._audio.currentTime = pct * this._audio.duration; // Notify footer and other inline players playing the same source - this.dispatchEvent(new CustomEvent("podcast-seek", { - bubbles: true, - composed: true, - detail: { - currentTime: this._audio.currentTime, - src: this._audio.src || this.getAttribute("src") || "", - }, - })); + this.dispatchEvent( + new CustomEvent("podcast-seek", { + bubbles: true, + composed: true, + detail: { + currentTime: this._audio.currentTime, + src: this._audio.src || this.getAttribute("src") || "", + }, + }), + ); } /** Seek to a chapter by index. */ @@ -755,14 +798,16 @@ class PodcastPlayer extends HTMLElement { }); this._currentChapterIndex = index; // Notify footer and other inline players of position change - this.dispatchEvent(new CustomEvent("podcast-seek", { - bubbles: true, - composed: true, - detail: { - currentTime: ch.time, - src: this._audio.src || this.getAttribute("src") || "", - }, - })); + this.dispatchEvent( + new CustomEvent("podcast-seek", { + bubbles: true, + composed: true, + detail: { + currentTime: ch.time, + src: this._audio.src || this.getAttribute("src") || "", + }, + }), + ); // Auto-play if paused if (this._audio.paused) { this._audio.play()?.catch(() => {}); @@ -826,7 +871,10 @@ class PodcastPlayer extends HTMLElement { this._audio.playbackRate = next; } this._els.rateBtn.textContent = next + "\u00d7"; - this._els.rateBtn.setAttribute("aria-label", this._t("player_speed_label", {rate: next+"\u00d7"})); + this._els.rateBtn.setAttribute( + "aria-label", + this._t("player_speed_label", { rate: next + "\u00d7" }), + ); this._dispatchStateChange({ playbackRate: next }); } @@ -840,8 +888,10 @@ class PodcastPlayer extends HTMLElement { this._els.progress.value = pct; this._els.progress.style.setProperty("--progress", pct + "%"); this._els.timeCurrent.textContent = this._fmtTime(this._audio.currentTime); - this._els.progress.setAttribute("aria-valuetext", - `${this._fmtTime(this._audio.currentTime)} of ${this._fmtTime(this._audio.duration)}`); + this._els.progress.setAttribute( + "aria-valuetext", + `${this._fmtTime(this._audio.currentTime)} of ${this._fmtTime(this._audio.duration)}`, + ); // Highlight active chapter this._updateActiveChapter(); @@ -856,7 +906,7 @@ class PodcastPlayer extends HTMLElement { /** Throttled wrapper around _savePlaybackState — respects SAVE_INTERVAL_MS. */ _savePlaybackStateThrottled() { const now = Date.now(); - if (this._lastSaveTs && (now - this._lastSaveTs) < PodcastPlayer.SAVE_INTERVAL_MS) return; + if (this._lastSaveTs && now - this._lastSaveTs < PodcastPlayer.SAVE_INTERVAL_MS) return; this._lastSaveTs = now; this._savePlaybackState(); } @@ -885,7 +935,8 @@ class PodcastPlayer extends HTMLElement { } _onPlay() { - this._els.playBtn.innerHTML = ''; + this._els.playBtn.innerHTML = + ''; this._els.playBtn.setAttribute("aria-label", this._t("player_pause")); this._els.playBtn.setAttribute("aria-pressed", "true"); this._els.playBtn.title = this._t("player_pause"); @@ -899,17 +950,19 @@ class PodcastPlayer extends HTMLElement { } // Notify a global footer (podcast-footer) that playback started - this.dispatchEvent(new CustomEvent("podcast-play", { - bubbles: true, - composed: true, - detail: { - src: this._audio.src || this.getAttribute("src") || "", - title: this.getAttribute("title") || "", - poster: this.getAttribute("poster") || "", - url: this._resolveUrl(), - currentTime: this._audio.currentTime, - }, - })); + this.dispatchEvent( + new CustomEvent("podcast-play", { + bubbles: true, + composed: true, + detail: { + src: this._audio.src || this.getAttribute("src") || "", + title: this.getAttribute("title") || "", + poster: this.getAttribute("poster") || "", + url: this._resolveUrl(), + currentTime: this._audio.currentTime, + }, + }), + ); // Mute the inline's own audio when a is present to prevent // double audio — the footer's