{{ this.summary }}
diff --git a/assets/static/narration.js b/assets/static/narration.js new file mode 100644 index 0000000..5efbbe1 --- /dev/null +++ b/assets/static/narration.js @@ -0,0 +1,87 @@ +document.querySelectorAll("[data-narration-player]").forEach((player) => { + const audio = player.querySelector("audio"); + const controls = player.querySelector("[data-narration-controls]"); + const toggle = player.querySelector("[data-narration-toggle]"); + const toggleIcon = player.querySelector("[data-narration-toggle-icon]"); + const progress = player.querySelector("[data-narration-progress]"); + const currentTime = player.querySelector("[data-narration-current]"); + const duration = player.querySelector("[data-narration-duration]"); + const speed = player.querySelector("[data-narration-speed]"); + let pendingSeek = null; + + if (!audio || !controls || !toggle || !toggleIcon || !progress || !currentTime || !duration || !speed) return; + + const formatTime = (seconds) => { + if (!Number.isFinite(seconds)) return "–:––"; + + const wholeSeconds = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(wholeSeconds / 60); + return `${minutes}:${String(wholeSeconds % 60).padStart(2, "0")}`; + }; + + const updateTimeline = () => { + const total = Number.isFinite(audio.duration) ? audio.duration : 0; + const position = pendingSeek ?? audio.currentTime; + progress.max = String(total); + progress.value = String(position); + currentTime.textContent = formatTime(position); + duration.textContent = formatTime(audio.duration); + progress.setAttribute( + "aria-valuetext", + `${formatTime(position)} of ${formatTime(audio.duration)}` + ); + }; + + const updateToggle = () => { + const isPlaying = !audio.paused && !audio.ended; + toggle.setAttribute("aria-label", isPlaying ? "Pause narration" : "Play narration"); + toggleIcon.textContent = isPlaying ? "Ⅱ" : "▶"; + }; + + audio.controls = false; + controls.hidden = false; + player.classList.add("is-enhanced"); + + toggle.addEventListener("click", async () => { + if (audio.paused || audio.ended) { + try { + await audio.play(); + } catch { + updateToggle(); + } + } else { + audio.pause(); + } + }); + + progress.addEventListener("input", () => { + const seekTime = Number.parseFloat(progress.value); + if (!Number.isFinite(seekTime)) return; + + pendingSeek = seekTime; + audio.currentTime = pendingSeek; + updateTimeline(); + }); + + audio.addEventListener("loadedmetadata", updateTimeline); + audio.addEventListener("durationchange", updateTimeline); + audio.addEventListener("timeupdate", updateTimeline); + audio.addEventListener("seeked", () => { + pendingSeek = null; + updateTimeline(); + }); + audio.addEventListener("play", updateToggle); + audio.addEventListener("pause", updateToggle); + audio.addEventListener("ended", updateToggle); + + speed.addEventListener("change", () => { + const playbackRate = Number.parseFloat(speed.value); + if (!Number.isFinite(playbackRate)) return; + + audio.defaultPlaybackRate = playbackRate; + audio.playbackRate = playbackRate; + }); + + updateTimeline(); + updateToggle(); +}); diff --git a/assets/static/style.css b/assets/static/style.css index 51a4084..84d0d3f 100644 --- a/assets/static/style.css +++ b/assets/static/style.css @@ -179,8 +179,12 @@ h1 { max-width: 880px; margin: 0; font-size: clamp(30px, 4vw, 45px); font-style: .article-entry-header .dek { margin-bottom: 0; } .blog-entry-body, .article-entry-body { grid-area: body; width: 100%; } -.post-info { +.post-meta { grid-area: info; + display: grid; + gap: 16px; +} +.post-info { border: 1px solid var(--rule); background: var(--panel); padding: 24px; @@ -216,6 +220,50 @@ h1 { max-width: 880px; margin: 0; font-size: clamp(30px, 4vw, 45px); font-style: .publication-links a:hover, .publication-links a:focus-visible { text-decoration: underline; text-underline-offset: 3px; } +.narration-player { + border: 1px solid var(--rule); + padding: 18px 20px; +} +.narration-title { + margin: 0 0 14px; + color: var(--muted); + font: 500 10px var(--mono); + letter-spacing: .14em; + text-transform: uppercase; +} +.narration-audio { display: block; width: 100%; height: 36px; color-scheme: dark; accent-color: var(--teal); } +.narration-player.is-enhanced .narration-audio { display: none; } +.narration-controls { grid-template-columns: 34px minmax(0, 1fr); align-items: center; gap: 12px; } +.narration-controls:not([hidden]) { display: grid; } +.narration-toggle { + width: 34px; + height: 34px; + border: 1px solid var(--rule); + background: transparent; + padding: 0; + color: var(--ink); + cursor: pointer; + font-size: 12px; +} +.narration-toggle:hover, +.narration-toggle:focus-visible { border-color: var(--teal); color: var(--teal); outline-color: var(--teal); } +.narration-timeline { display: grid; gap: 4px; min-width: 0; } +.narration-timeline input { width: 100%; margin: 0; accent-color: var(--teal); cursor: pointer; } +.narration-time { color: var(--muted); font: 500 9px var(--mono); letter-spacing: .04em; } +.narration-details { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 14px; } +.narration-details p { margin: 0; color: var(--dim); font-size: 11px; } +.narration-details label { display: flex; align-items: center; gap: 7px; color: var(--dim); font: 500 9px var(--mono); letter-spacing: .08em; text-transform: uppercase; } +.narration-speed { + border: 1px solid var(--rule); + border-radius: 0; + background: var(--panel); + padding: 5px 7px; + color: var(--ink); + font: 500 10px var(--mono); +} +.narration-speed:hover, +.narration-speed:focus-visible { border-color: var(--teal); outline-color: var(--teal); } + .project-hero { border-bottom: 1px solid var(--rule); padding: 62px 0 48px; } .project-hero-inner { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 44px; align-items: end; } .project-identity { display: flex; gap: 26px; align-items: flex-start; } @@ -373,6 +421,8 @@ h1 { max-width: 880px; margin: 0; font-size: clamp(30px, 4vw, 45px); font-style: padding-block: 44px 65px; } .post-info { padding: 20px; } + .narration-player { padding: 18px; } + .narration-details { align-items: flex-start; flex-direction: column-reverse; } .footer-inner { align-items: flex-start; flex-direction: column; padding-block: 28px; } .site-footer nav { flex-wrap: wrap; } } diff --git a/templates/base.html b/templates/base.html index 5432ed9..84c906b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,9 +9,10 @@ - + +
{{ this.summary }}
{{ this.summary }}
-