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:
parent
c8c44a5d23
commit
575749b774
2 changed files with 221 additions and 1 deletions
7
migrations/0009_story_audiobook.sql
Normal file
7
migrations/0009_story_audiobook.sql
Normal 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;
|
||||
215
skald/src/web.rs
215
skald/src/web.rs
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue