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: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 {
|
.btn--primary {
|
||||||
background: var(--tg);
|
background: var(--tg);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -569,11 +578,75 @@ img { max-width: 100%; display: block; }
|
|||||||
.sources a:hover { color: var(--tg-dark); padding-left: 5px; }
|
.sources a:hover { color: var(--tg-dark); padding-left: 5px; }
|
||||||
|
|
||||||
.modal__foot {
|
.modal__foot {
|
||||||
padding: 18px 32px;
|
padding: 16px 32px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
background: #fffdfa;
|
background: #fffdfa;
|
||||||
display: flex;
|
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 {
|
.modal__loading {
|
||||||
@@ -604,7 +677,7 @@ img { max-width: 100%; display: block; }
|
|||||||
}
|
}
|
||||||
.modal__head { padding: 26px 20px 18px; }
|
.modal__head { padding: 26px 20px 18px; }
|
||||||
.modal__body { padding: 20px; }
|
.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; }
|
.modal__foot .btn { width: 100%; justify-content: center; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -101,11 +101,24 @@
|
|||||||
<div class="modal__body" id="modalBody"></div>
|
<div class="modal__body" id="modalBody"></div>
|
||||||
|
|
||||||
<div class="modal__foot">
|
<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>
|
<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>
|
<span data-i18n="modal.download"></span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<audio id="audio" preload="metadata"></audio>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,11 @@
|
|||||||
"modal.transcript": "Transcript",
|
"modal.transcript": "Transcript",
|
||||||
"modal.sources": "Sources",
|
"modal.sources": "Sources",
|
||||||
"modal.download": "Download audio",
|
"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.",
|
"footer.text": "Daily Spanish B1 news podcast · Delivered via Telegram.",
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
@@ -80,6 +85,11 @@
|
|||||||
"modal.transcript": "Transkript",
|
"modal.transcript": "Transkript",
|
||||||
"modal.sources": "Quellen",
|
"modal.sources": "Quellen",
|
||||||
"modal.download": "Audio herunterladen",
|
"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.",
|
"footer.text": "Täglicher Spanisch-B1-Nachrichten-Podcast · Geliefert über Telegram.",
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
@@ -108,6 +118,11 @@
|
|||||||
"modal.transcript": "Transcripción",
|
"modal.transcript": "Transcripción",
|
||||||
"modal.sources": "Fuentes",
|
"modal.sources": "Fuentes",
|
||||||
"modal.download": "Descargar audio",
|
"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.",
|
"footer.text": "Podcast diario de noticias en español B1 · Enviado por Telegram.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -160,6 +175,12 @@
|
|||||||
modalTitle: document.getElementById("modalTitle"),
|
modalTitle: document.getElementById("modalTitle"),
|
||||||
modalBody: document.getElementById("modalBody"),
|
modalBody: document.getElementById("modalBody"),
|
||||||
modalDownload: document.getElementById("modalDownload"),
|
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")),
|
langButtons: Array.from(document.querySelectorAll(".lang-switch button")),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,6 +304,11 @@
|
|||||||
el.langButtons.forEach(function (btn) {
|
el.langButtons.forEach(function (btn) {
|
||||||
btn.classList.toggle("is-active", btn.getAttribute("data-lang") === lang);
|
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) {
|
function setLang(next) {
|
||||||
@@ -433,8 +459,8 @@
|
|||||||
a.setAttribute("rel", "noopener noreferrer");
|
a.setAttribute("rel", "noopener noreferrer");
|
||||||
});
|
});
|
||||||
|
|
||||||
el.modalDownload.href = audioUrl(ep.date);
|
el.modalDownload.dataset.url = audioUrl(ep.date);
|
||||||
el.modalDownload.setAttribute("download", "learn-and-prep-" + (ep.date || "episode") + ".mp3");
|
el.modalDownload.dataset.filename = "learn-and-prep-" + (ep.date || "episode") + ".mp3";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
@@ -447,6 +473,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
el.audio.pause();
|
||||||
el.modal.hidden = true;
|
el.modal.hidden = true;
|
||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
document.removeEventListener("keydown", onKeydown);
|
document.removeEventListener("keydown", onKeydown);
|
||||||
@@ -463,7 +490,10 @@
|
|||||||
el.modalDate.textContent = formatDate(date);
|
el.modalDate.textContent = formatDate(date);
|
||||||
el.modalTitle.textContent = "";
|
el.modalTitle.textContent = "";
|
||||||
el.modalBody.innerHTML = '<div class="modal__loading">' + escapeHtml(t("modal.loading")) + "</div>";
|
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 {
|
try {
|
||||||
const payload = await fetchJson(detailUrl(date));
|
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
|
Events
|
||||||
--------------------------------------------------------- */
|
--------------------------------------------------------- */
|
||||||
@@ -502,6 +703,9 @@
|
|||||||
el.modal.addEventListener("click", function (e) {
|
el.modal.addEventListener("click", function (e) {
|
||||||
if (e.target.closest("[data-close]")) closeModal();
|
if (e.target.closest("[data-close]")) closeModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
el.modalDownload.addEventListener("click", downloadAudio);
|
||||||
|
bindPlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------
|
/* ---------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user