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 = '"; - 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(); } /* ---------------------------------------------------------