This commit is contained in:
oliver committed 2026-09-25 13:59:25 -03:00
1 parent 86547731c8
commit ce5eae7bce
3 files changed
+298 -8

No files matched your search

+76 -3
View File
@@ -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; }
}
+15 -2
View File
@@ -101,11 +101,24 @@
<div class="modal__body" id="modalBody"></div>
<div class="modal__foot">
<a class="btn btn--primary" id="modalDownload" href="#" download>
<div class="player" id="player">
<button type="button" class="player__btn" id="playerToggle" aria-label="Play">
<svg class="player__icon player__icon--play" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.8-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14z"/></svg>
<svg class="player__icon player__icon--pause" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>
</button>
<div class="player__track" id="playerTrack" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="player__fill" id="playerFill"></div>
</div>
<span class="player__time" id="playerTime">0:00 / 0:00</span>
</div>
<button type="button" class="btn btn--primary" id="modalDownload">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"/></svg>
<span data-i18n="modal.download"></span>
</a>
</button>
</div>
<audio id="audio" preload="metadata"></audio>
</div>
</div>
+207 -3
View File
@@ -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 = '<div class="modal__loading">' + escapeHtml(t("modal.loading")) + "</div>";
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();
}
/* ---------------------------------------------------------