This commit is contained in:
@@ -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();
|
||||
});
|
||||
+51
-1
@@ -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; }
|
||||
}
|
||||
|
||||
+2
-1
@@ -9,9 +9,10 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500&family=Playfair+Display:ital,wght@0,600;0,700;1,500;1,600&display=swap" rel="stylesheet">
|
||||
<link rel="icon" href="{{ '/static/favicon.svg'|url }}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ '/static/style.css'|url }}?v=12">
|
||||
<link rel="stylesheet" href="{{ '/static/style.css'|url }}?v=13">
|
||||
<script src="{{ '/static/tags.js'|url }}?v=1" defer></script>
|
||||
<script src="{{ '/static/activity.js'|url }}?v=1" defer></script>
|
||||
<script src="{{ '/static/narration.js'|url }}?v=1" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
|
||||
+31
-24
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/tag-links.html" import tag_links with context %}
|
||||
{% from "macros/narration-player.html" import narration_player with context %}
|
||||
{% block body %}
|
||||
{% if this.parent._id == 'blog' %}
|
||||
<article class="blog-post-layout shell">
|
||||
@@ -9,25 +10,28 @@
|
||||
<p class="dek">{{ this.summary }}</p>
|
||||
</header>
|
||||
|
||||
<aside class="post-info" aria-label="Post information">
|
||||
<p class="post-info-title">Post information</p>
|
||||
<dl>
|
||||
<div class="post-info-row">
|
||||
<dt>Author</dt>
|
||||
<dd>{{ this.author }}</dd>
|
||||
</div>
|
||||
<div class="post-info-row">
|
||||
<dt>Published</dt>
|
||||
<dd><time datetime="{{ this.date }}">{{ this.date|dateformat('long') }}</time></dd>
|
||||
</div>
|
||||
<div class="post-info-row">
|
||||
<dt>Tags</dt>
|
||||
<dd class="post-tags">
|
||||
{{ tag_links(this.tags) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
<div class="post-meta">
|
||||
<aside class="post-info" aria-label="Post information">
|
||||
<p class="post-info-title">Post information</p>
|
||||
<dl>
|
||||
<div class="post-info-row">
|
||||
<dt>Author</dt>
|
||||
<dd>{{ this.author }}</dd>
|
||||
</div>
|
||||
<div class="post-info-row">
|
||||
<dt>Published</dt>
|
||||
<dd><time datetime="{{ this.date }}">{{ this.date|dateformat('long') }}</time></dd>
|
||||
</div>
|
||||
<div class="post-info-row">
|
||||
<dt>Tags</dt>
|
||||
<dd class="post-tags">
|
||||
{{ tag_links(this.tags) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
{{ narration_player(this) }}
|
||||
</div>
|
||||
|
||||
<div class="blog-entry-body prose">
|
||||
{{ this.body }}
|
||||
@@ -44,9 +48,10 @@
|
||||
<p class="dek">{{ this.summary }}</p>
|
||||
</header>
|
||||
|
||||
<aside class="post-info article-info" aria-label="Article information">
|
||||
<p class="post-info-title">Article information</p>
|
||||
<dl>
|
||||
<div class="post-meta">
|
||||
<aside class="post-info article-info" aria-label="Article information">
|
||||
<p class="post-info-title">Article information</p>
|
||||
<dl>
|
||||
{% if this.author %}
|
||||
<div class="post-info-row">
|
||||
<dt>Author</dt>
|
||||
@@ -91,8 +96,10 @@
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</aside>
|
||||
</dl>
|
||||
</aside>
|
||||
{{ narration_player(this) }}
|
||||
</div>
|
||||
|
||||
<div class="article-entry-body prose">
|
||||
{{ this.body }}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{% macro narration_player(record) %}
|
||||
{% set narration = record.attachments.get('narration.mp3') %}
|
||||
{% if narration %}
|
||||
<section class="narration-player" data-narration-player aria-label="Narrated version">
|
||||
<h2 class="narration-title">Narrated version</h2>
|
||||
<audio class="narration-audio" controls preload="metadata" aria-label="Narration of {{ record.title }}">
|
||||
<source src="{{ narration|url }}" type="audio/mpeg">
|
||||
Your browser does not support audio playback. <a href="{{ narration|url }}">Download the narration</a>.
|
||||
</audio>
|
||||
<div class="narration-controls" data-narration-controls hidden>
|
||||
<button class="narration-toggle" type="button" data-narration-toggle aria-label="Play narration">
|
||||
<span aria-hidden="true" data-narration-toggle-icon>▶</span>
|
||||
</button>
|
||||
<div class="narration-timeline">
|
||||
<input type="range" min="0" max="0" value="0" step="0.1" data-narration-progress aria-label="Seek through narration" aria-valuetext="0:00 of unknown duration">
|
||||
<output class="narration-time" aria-live="off">
|
||||
<span data-narration-current>0:00</span> / <span data-narration-duration>–:––</span>
|
||||
</output>
|
||||
</div>
|
||||
</div>
|
||||
<div class="narration-details">
|
||||
<p>Narrated by the author</p>
|
||||
<label>
|
||||
<span>Playback speed</span>
|
||||
<select class="narration-speed" data-narration-speed aria-label="Playback speed">
|
||||
<option value="0.75">0.75×</option>
|
||||
<option value="1" selected>1×</option>
|
||||
<option value="1.25">1.25×</option>
|
||||
<option value="1.5">1.5×</option>
|
||||
<option value="1.75">1.75×</option>
|
||||
<option value="2">2×</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
Reference in New Issue
Block a user