player
This commit is contained in:
1 parent
86547731c8
commit
ce5eae7bce
3 files changed
+298
-8
No files matched your search
+76
-3
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user