diff --git a/css/style.css b/css/style.css
index b42207e..a0e6cd4 100644
--- a/css/style.css
+++ b/css/style.css
@@ -182,6 +182,15 @@ img { max-width: 100%; display: block; }
.btn:active { transform: translateY(1px); }
+.btn:disabled {
+ cursor: progress;
+ opacity: 0.75;
+}
+
+.btn.is-loading .icon { animation: pulse 1s ease-in-out infinite; }
+
+@keyframes pulse { 50% { opacity: 0.3; } }
+
.btn--primary {
background: var(--tg);
color: #fff;
@@ -569,11 +578,75 @@ img { max-width: 100%; display: block; }
.sources a:hover { color: var(--tg-dark); padding-left: 5px; }
.modal__foot {
- padding: 18px 32px;
+ padding: 16px 32px;
border-top: 1px solid var(--line);
background: #fffdfa;
display: flex;
- justify-content: flex-end;
+ align-items: center;
+ gap: 18px;
+}
+
+/* ---------------- Audio player ---------------- */
+.player {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.player__btn {
+ flex: none;
+ width: 46px;
+ height: 46px;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: var(--ink);
+ color: #fff;
+ cursor: pointer;
+ transition: background 0.18s ease, transform 0.18s ease;
+}
+
+.player__btn:hover { background: #1d232c; transform: scale(1.06); }
+.player__btn:active { transform: scale(0.96); }
+.player__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+
+.player__icon { width: 20px; height: 20px; }
+.player__icon--pause { display: none; }
+.player.is-playing .player__icon--play { display: none; }
+.player.is-playing .player__icon--pause { display: block; }
+
+.player__track {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 60px;
+ height: 6px;
+ border-radius: 999px;
+ background: #e9e2d9;
+ cursor: pointer;
+ touch-action: none;
+ transition: background 0.18s ease;
+}
+
+.player__track:hover { background: #ded4c7; }
+.player__track:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
+
+.player__fill {
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 0%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
+}
+
+.player__time {
+ flex: none;
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--muted);
+ white-space: nowrap;
}
.modal__loading {
@@ -604,7 +677,7 @@ img { max-width: 100%; display: block; }
}
.modal__head { padding: 26px 20px 18px; }
.modal__body { padding: 20px; }
- .modal__foot { padding: 16px 20px; }
+ .modal__foot { padding: 16px 20px; flex-direction: column; align-items: stretch; gap: 14px; }
.modal__foot .btn { width: 100%; justify-content: center; }
}
diff --git a/index.html b/index.html
index baa3eee..3723a21 100644
--- a/index.html
+++ b/index.html
@@ -101,11 +101,24 @@
+
+
diff --git a/js/app.js b/js/app.js
index 54a091c..e75d00c 100644
--- a/js/app.js
+++ b/js/app.js
@@ -52,6 +52,11 @@
"modal.transcript": "Transcript",
"modal.sources": "Sources",
"modal.download": "Download audio",
+ "modal.downloading": "Downloading…",
+ "modal.downloaded": "Saved",
+ "player.play": "Play",
+ "player.pause": "Pause",
+ "player.seek": "Seek in episode",
"footer.text": "Daily Spanish B1 news podcast · Delivered via Telegram.",
},
de: {
@@ -80,6 +85,11 @@
"modal.transcript": "Transkript",
"modal.sources": "Quellen",
"modal.download": "Audio herunterladen",
+ "modal.downloading": "Wird geladen…",
+ "modal.downloaded": "Gespeichert",
+ "player.play": "Abspielen",
+ "player.pause": "Pausieren",
+ "player.seek": "Im Podcast spulen",
"footer.text": "Täglicher Spanisch-B1-Nachrichten-Podcast · Geliefert über Telegram.",
},
es: {
@@ -108,6 +118,11 @@
"modal.transcript": "Transcripción",
"modal.sources": "Fuentes",
"modal.download": "Descargar audio",
+ "modal.downloading": "Descargando…",
+ "modal.downloaded": "Guardado",
+ "player.play": "Reproducir",
+ "player.pause": "Pausar",
+ "player.seek": "Buscar en el episodio",
"footer.text": "Podcast diario de noticias en español B1 · Enviado por Telegram.",
},
};
@@ -160,6 +175,12 @@
modalTitle: document.getElementById("modalTitle"),
modalBody: document.getElementById("modalBody"),
modalDownload: document.getElementById("modalDownload"),
+ audio: document.getElementById("audio"),
+ player: document.getElementById("player"),
+ playerToggle: document.getElementById("playerToggle"),
+ playerTrack: document.getElementById("playerTrack"),
+ playerFill: document.getElementById("playerFill"),
+ playerTime: document.getElementById("playerTime"),
langButtons: Array.from(document.querySelectorAll(".lang-switch button")),
};
@@ -283,6 +304,11 @@
el.langButtons.forEach(function (btn) {
btn.classList.toggle("is-active", btn.getAttribute("data-lang") === lang);
});
+
+ if (el.playerToggle) {
+ el.playerToggle.setAttribute("aria-label", el.audio.paused ? t("player.play") : t("player.pause"));
+ }
+ if (el.playerTrack) el.playerTrack.setAttribute("aria-label", t("player.seek"));
}
function setLang(next) {
@@ -433,8 +459,8 @@
a.setAttribute("rel", "noopener noreferrer");
});
- el.modalDownload.href = audioUrl(ep.date);
- el.modalDownload.setAttribute("download", "learn-and-prep-" + (ep.date || "episode") + ".mp3");
+ el.modalDownload.dataset.url = audioUrl(ep.date);
+ el.modalDownload.dataset.filename = "learn-and-prep-" + (ep.date || "episode") + ".mp3";
}
function openModal() {
@@ -447,6 +473,7 @@
}
function closeModal() {
+ el.audio.pause();
el.modal.hidden = true;
document.body.style.overflow = "";
document.removeEventListener("keydown", onKeydown);
@@ -463,7 +490,10 @@
el.modalDate.textContent = formatDate(date);
el.modalTitle.textContent = "";
el.modalBody.innerHTML = '' + escapeHtml(t("modal.loading")) + "
";
- el.modalDownload.href = audioUrl(date);
+ setDownloadState("idle");
+ el.modalDownload.dataset.url = audioUrl(date);
+ el.modalDownload.dataset.filename = "learn-and-prep-" + date + ".mp3";
+ setPlayerSource(audioUrl(date));
try {
const payload = await fetchJson(detailUrl(date));
@@ -482,6 +512,177 @@
}
}
+ /* ---------------------------------------------------------
+ Audio player
+ --------------------------------------------------------- */
+ function formatTime(seconds) {
+ if (!isFinite(seconds) || seconds < 0) seconds = 0;
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return m + ":" + (s < 10 ? "0" + s : String(s));
+ }
+
+ function updatePlayerUI() {
+ const duration = el.audio.duration;
+ const current = el.audio.currentTime;
+ const pct = isFinite(duration) && duration > 0 ? (current / duration) * 100 : 0;
+ el.playerFill.style.width = pct + "%";
+ el.playerTrack.setAttribute("aria-valuenow", String(Math.round(pct)));
+ el.playerTime.textContent = formatTime(current) + " / " + formatTime(duration);
+ }
+
+ function setPlayerSource(url) {
+ el.audio.pause();
+ el.player.classList.remove("is-playing");
+ el.playerFill.style.width = "0%";
+ el.playerTime.textContent = "0:00 / 0:00";
+ el.playerToggle.setAttribute("aria-label", t("player.play"));
+ if (url) {
+ el.audio.src = url;
+ } else {
+ el.audio.removeAttribute("src");
+ }
+ el.audio.load();
+ }
+
+ function togglePlay() {
+ if (!el.audio.getAttribute("src")) return;
+ if (el.audio.paused) {
+ const promise = el.audio.play();
+ if (promise && typeof promise.catch === "function") promise.catch(function () {});
+ } else {
+ el.audio.pause();
+ }
+ }
+
+ function seekToRatio(ratio) {
+ const duration = el.audio.duration;
+ if (!isFinite(duration) || duration <= 0) return;
+ el.audio.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
+ updatePlayerUI();
+ }
+
+ function seekFromPointer(e) {
+ const rect = el.playerTrack.getBoundingClientRect();
+ if (!rect.width) return;
+ seekToRatio((e.clientX - rect.left) / rect.width);
+ }
+
+ function bindPlayer() {
+ el.playerToggle.addEventListener("click", togglePlay);
+
+ el.audio.addEventListener("play", function () {
+ el.player.classList.add("is-playing");
+ el.playerToggle.setAttribute("aria-label", t("player.pause"));
+ });
+ el.audio.addEventListener("pause", function () {
+ el.player.classList.remove("is-playing");
+ el.playerToggle.setAttribute("aria-label", t("player.play"));
+ });
+ el.audio.addEventListener("ended", function () {
+ el.player.classList.remove("is-playing");
+ el.audio.currentTime = 0;
+ updatePlayerUI();
+ });
+ el.audio.addEventListener("timeupdate", updatePlayerUI);
+ el.audio.addEventListener("loadedmetadata", updatePlayerUI);
+ el.audio.addEventListener("durationchange", updatePlayerUI);
+
+ let dragging = false;
+ el.playerTrack.addEventListener("pointerdown", function (e) {
+ dragging = true;
+ try {
+ el.playerTrack.setPointerCapture(e.pointerId);
+ } catch (err) {
+ /* pointer capture unsupported — dragging still works while over the track */
+ }
+ seekFromPointer(e);
+ });
+ el.playerTrack.addEventListener("pointermove", function (e) {
+ if (dragging) seekFromPointer(e);
+ });
+ el.playerTrack.addEventListener("pointerup", function () {
+ dragging = false;
+ });
+ el.playerTrack.addEventListener("pointercancel", function () {
+ dragging = false;
+ });
+
+ el.playerTrack.addEventListener("keydown", function (e) {
+ const duration = el.audio.duration || 0;
+ if (e.key === "ArrowRight") {
+ el.audio.currentTime = Math.min(duration, el.audio.currentTime + 5);
+ updatePlayerUI();
+ e.preventDefault();
+ } else if (e.key === "ArrowLeft") {
+ el.audio.currentTime = Math.max(0, el.audio.currentTime - 5);
+ updatePlayerUI();
+ e.preventDefault();
+ }
+ });
+ }
+
+ /* ---------------------------------------------------------
+ Background download
+ --------------------------------------------------------- */
+ let downloadResetTimer = null;
+
+ function downloadLabel() {
+ return el.modalDownload.querySelector("span[data-i18n]");
+ }
+
+ function setDownloadState(state) {
+ clearTimeout(downloadResetTimer);
+ const label = downloadLabel();
+ el.modalDownload.classList.toggle("is-loading", state === "loading");
+ el.modalDownload.disabled = state === "loading";
+
+ if (state === "loading") {
+ if (label) label.textContent = t("modal.downloading");
+ } else if (state === "done") {
+ if (label) label.textContent = t("modal.downloaded");
+ downloadResetTimer = setTimeout(function () {
+ if (label) label.textContent = t("modal.download");
+ }, 2500);
+ } else if (label) {
+ label.textContent = t("modal.download");
+ }
+ }
+
+ function triggerDownload(href, filename) {
+ const a = document.createElement("a");
+ a.href = href;
+ a.download = filename;
+ a.rel = "noopener";
+ a.style.display = "none";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ }
+
+ async function downloadAudio() {
+ const url = el.modalDownload.dataset.url;
+ if (!url || el.modalDownload.disabled) return;
+ const filename = el.modalDownload.dataset.filename || "episode.mp3";
+
+ setDownloadState("loading");
+ try {
+ const res = await fetch(url, { mode: "cors" });
+ if (!res.ok) throw new Error("HTTP " + res.status);
+ const blob = await res.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ triggerDownload(objectUrl, filename);
+ setTimeout(function () {
+ URL.revokeObjectURL(objectUrl);
+ }, 10000);
+ setDownloadState("done");
+ } catch (err) {
+ // CORS or network failure: fall back to letting the browser fetch it directly
+ triggerDownload(url, filename);
+ setDownloadState("idle");
+ }
+ }
+
/* ---------------------------------------------------------
Events
--------------------------------------------------------- */
@@ -502,6 +703,9 @@
el.modal.addEventListener("click", function (e) {
if (e.target.closest("[data-close]")) closeModal();
});
+
+ el.modalDownload.addEventListener("click", downloadAudio);
+ bindPlayer();
}
/* ---------------------------------------------------------