web: audiobook player — stitched-file playback with chapter seek

Adds GET /stories/{id}/listen: one <audio> element over a story's
stitched audiobook file plus a clickable chapter list. Clicking a
chapter seeks; the chapter under the playhead highlights as it
plays. Chapter offsets are summed from each chapter's latest
succeeded narration_run duration — the same order the file was
stitched. One small inline script, the web UI's first JS.

New stories.audiobook_path column (migration 0009) holds the
served path; the story page shows a "listen" action when set.
This commit is contained in:
Kayos 2026-05-15 07:30:56 -07:00
parent c8c44a5d23
commit 575749b774
2 changed files with 221 additions and 1 deletions

View file

@ -0,0 +1,7 @@
-- A story can have a single stitched audiobook — all its chapter
-- renders concatenated into one chaptered file (see the m4b built
-- from per-chapter narration_runs). audiobook_path is the path the
-- web server serves it from (e.g. /audio/The-Coast-Down.m4b); NULL
-- means no audiobook has been stitched yet.
ALTER TABLE stories
ADD COLUMN IF NOT EXISTS audiobook_path text;

View file

@ -45,6 +45,7 @@ pub fn router(state: WebState) -> Router {
"/stories/{id}/chapters/{n}/narrate",
post(chapter_narrate_fire),
)
.route("/stories/{id}/listen", get(listen_view))
.route("/stories/{id}/runs", get(runs_view))
.nest_service("/audio", ServeDir::new(audio_dir))
.with_state(Arc::new(state))
@ -402,7 +403,18 @@ async fn story_detail(
.await
.unwrap_or_default();
let panel = story_panel(&story, &chapters, &characters, &canon_facts);
let has_audiobook: bool = sqlx::query_scalar::<_, Option<String>>(
"SELECT audiobook_path FROM stories WHERE id = $1",
)
.bind(id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.flatten()
.is_some();
let panel = story_panel(&story, &chapters, &characters, &canon_facts, has_audiobook);
Ok(Html(render_shell(&stories, Some(id), panel).into_string()))
}
@ -513,6 +525,73 @@ async fn chapter_narrate_fire(
Ok(Redirect::to(&format!("/stories/{id}/chapters/{n}")))
}
/// One chapter as the audiobook player sees it: its start offset
/// (seconds from the top of the stitched file) and its length.
#[derive(Debug, Clone)]
struct AudiobookChapter {
n: i32,
title: Option<String>,
offset_seconds: f64,
duration_seconds: f64,
}
async fn listen_view(
State(state): State<Arc<WebState>>,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let stories = fetch_stories(&state.pool).await;
let Some(story) = stories.iter().find(|s| s.id == id).cloned() else {
return Err(StatusCode::NOT_FOUND);
};
let audiobook_path: Option<String> = sqlx::query_scalar::<_, Option<String>>(
"SELECT audiobook_path FROM stories WHERE id = $1",
)
.bind(id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.flatten();
// Per-chapter durations come from each chapter's most recent
// succeeded narration_run. Summed cumulatively in chapter order
// they give every chapter's start offset inside the stitched
// file — the same order the file was stitched in.
let rows: Vec<(i32, Option<String>, Option<f32>)> = sqlx::query_as(
r#"
SELECT c.n, c.title,
(SELECT nr.duration_seconds FROM narration_runs nr
WHERE nr.chapter_id = c.id AND nr.status = 'succeeded'
AND nr.duration_seconds IS NOT NULL
ORDER BY nr.ended_at DESC LIMIT 1)
FROM chapters c
WHERE c.story_id = $1
ORDER BY c.n
"#,
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let mut chapters = Vec::new();
let mut cursor = 0.0_f64;
for (n, title, dur) in rows {
let d = dur.unwrap_or(0.0) as f64;
chapters.push(AudiobookChapter {
n,
title,
offset_seconds: cursor,
duration_seconds: d,
});
cursor += d;
}
let panel = audiobook_panel(&story, audiobook_path.as_deref(), &chapters);
Ok(Html(render_shell(&stories, Some(id), panel).into_string()))
}
async fn runs_view(
State(state): State<Arc<WebState>>,
Path(id): Path<Uuid>,
@ -714,6 +793,7 @@ fn story_panel(
chapters: &[(i32, Option<String>, i32, bool)],
characters: &[(String, String, String)],
canon_facts: &[(String, String, String)],
has_audiobook: bool,
) -> Markup {
let real_chars: Vec<_> = characters.iter().filter(|c| c.1 == "real").collect();
let fictional_chars: Vec<_> = characters.iter().filter(|c| c.1 == "fictional").collect();
@ -731,6 +811,9 @@ fn story_panel(
}
nav.story-actions {
a.action-primary href=(format!("/stories/{}/continue", s.id)) { "✦ continue this saga" }
@if has_audiobook {
a.action-listen href=(format!("/stories/{}/listen", s.id)) { "♪ listen" }
}
a.action-secondary href=(format!("/stories/{}/runs", s.id)) { "generation log →" }
}
@ -977,6 +1060,96 @@ fn runs_panel(story_id: Uuid, runs: &[(Uuid, String, String, DateTime<Utc>, Opti
}
}
/// Audiobook player — one `<audio>` element over the whole stitched
/// file plus a clickable chapter list. Clicking a chapter seeks; the
/// chapter under the playhead highlights as it plays. All client-side
/// in one small inline script (the file's only JS).
fn audiobook_panel(
story: &StoryRow,
audiobook_path: Option<&str>,
chapters: &[AudiobookChapter],
) -> Markup {
html! {
article.audiobook {
a.back href=(format!("/stories/{}", story.id)) { "← back to story" }
h1 { (story.title) }
(ornament())
@match audiobook_path {
Some(path) => {
@let basename = path.rsplit('/').next().unwrap_or(path);
@let audio_url = format!("/audio/{}", basename);
@let total: f64 = chapters.iter().map(|c| c.duration_seconds).sum();
p.muted {
(chapters.len()) " chapters · " (fmt_hms(total)) " · "
a href=(audio_url) download=(basename) { "download" }
}
audio #book controls preload="metadata" src=(audio_url) {}
ol.audiobook-chapters {
@for c in chapters {
@let end = c.offset_seconds + c.duration_seconds;
li.ab-chapter
data-seek=(format!("{:.3}", c.offset_seconds))
data-end=(format!("{:.3}", end))
{
span.ab-n { "Chapter " (c.n) }
@if let Some(t) = &c.title {
span.ab-title { (strip_chapter_prefix(t, c.n)) }
}
span.ab-time { (fmt_hms(c.offset_seconds)) }
}
}
}
script {
(maud::PreEscaped(AUDIOBOOK_JS))
}
}
None => {
p.muted {
"No audiobook stitched for this saga yet. Render every "
"chapter to audio, then stitch the per-chapter files into one."
}
}
}
}
}
}
const AUDIOBOOK_JS: &str = r#"
(function () {
var audio = document.getElementById('book');
if (!audio) return;
var rows = Array.prototype.slice.call(document.querySelectorAll('.ab-chapter'));
rows.forEach(function (row) {
row.addEventListener('click', function () {
var t = parseFloat(row.getAttribute('data-seek'));
if (!isNaN(t)) { audio.currentTime = t; audio.play(); }
});
});
audio.addEventListener('timeupdate', function () {
var now = audio.currentTime;
rows.forEach(function (row) {
var s = parseFloat(row.getAttribute('data-seek'));
var e = parseFloat(row.getAttribute('data-end'));
if (now >= s && now < e) { row.classList.add('playing'); }
else { row.classList.remove('playing'); }
});
});
})();
"#;
/// Format seconds as H:MM:SS, or M:SS when under an hour.
fn fmt_hms(s: f64) -> String {
let total = s.max(0.0) as i64;
let h = total / 3600;
let m = (total % 3600) / 60;
let sec = total % 60;
if h > 0 {
format!("{h}:{m:02}:{sec:02}")
} else {
format!("{m}:{sec:02}")
}
}
// ─── helpers ─────────────────────────────────────────────────────
/// Knotwork divider — a small SVG ornament used as section break.
@ -1318,6 +1491,45 @@ code { font-family: var(--mono); font-size: 0.9em; background: var(--surface-2);
.empty { color: var(--ink-faint); font-style: italic; }
/* ─── audiobook player ─────────────────────────────────────── */
.action-listen {
font-family: var(--display); letter-spacing: 2px; font-size: 12px;
text-transform: uppercase; color: var(--bronze);
border: 1px solid var(--bronze-dim); padding: 9px 18px;
background: var(--surface);
}
.action-listen:hover { color: var(--bg); background: var(--bronze); border-color: var(--bronze); }
.audiobook .back {
display: inline-block; color: var(--ink-faint); font-size: 12px;
margin-bottom: 18px; letter-spacing: 0.5px;
}
.audiobook h1 {
font-family: var(--display); font-size: 32px; color: var(--ink);
margin: 0; font-weight: 700; letter-spacing: 1px;
}
.audiobook > .muted { font-size: 13px; margin: 0 0 14px 0; }
.audiobook > .muted a { color: var(--bronze); }
.audiobook audio { width: 100%; margin: 4px 0 26px 0; }
.audiobook-chapters { list-style: none; margin: 0; padding: 0; }
.ab-chapter {
display: grid; grid-template-columns: 120px 1fr auto; gap: 20px;
align-items: baseline; padding: 13px 16px; cursor: pointer;
border-left: 2px solid transparent; border-bottom: 1px solid var(--surface-2);
transition: background 80ms ease;
}
.ab-chapter:hover { background: var(--surface-2); border-left-color: var(--bronze-dim); }
.ab-chapter.playing { background: var(--surface-2); border-left-color: var(--accent); }
.ab-n {
font-family: var(--mono); font-size: 12px; color: var(--bronze-dim);
letter-spacing: 1px;
}
.ab-chapter.playing .ab-n { color: var(--accent); }
.ab-title { font-family: var(--serif); font-size: 16px; color: var(--ink); }
.ab-time {
font-family: var(--mono); font-size: 12px; color: var(--ink-faint);
text-align: right;
}
/* ─── forms (new-saga + continue) ──────────────────────────── */
.form-panel h1 {
font-family: var(--display); font-size: 30px; color: var(--ink);
@ -1379,6 +1591,7 @@ code { font-family: var(--mono); font-size: 0.9em; background: var(--surface-2);
.topnav { margin-left: 0; width: 100%; padding-top: 6px; }
.chapter-list a { grid-template-columns: 80px 1fr auto; }
.chapter-list .wc { display: none; }
.ab-chapter { grid-template-columns: 80px 1fr auto; gap: 12px; }
.char-list li { grid-template-columns: 1fr; gap: 4px; }
.cname { font-size: 14px; }
.brand { font-size: 22px; letter-spacing: 3px; }