feat: server-side TTS audio generation, audio queue panel, and home screen fix
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

- Add POST /ui/audio/{slug}/{n} endpoint: calls Kokoro-FastAPI server-side,
  writes MP3 atomically to disk, deduplicates concurrent requests via
  in-flight channel map, wraps route with 10-min TimeoutHandler
- Add GET /ui/audio-file/{slug}/{n} endpoint: serves cached MP3 with 1-day
  cache headers
- Add AudioDir/AudioPath helpers to writer.go
- Rewrite JS TTS: remove all MSE/blob/stream code; use plain fetch to
  generate endpoint then set audio.src to returned URL
- Add AbortController to generateAudio; abort on stop() and navigation
- Keep voice/speed controls disabled until setPlaying() fires
- Reset prefetchFired on voice/speed change
- Upgrade prefetch from fire-and-forget to promise chain updating queue badge
- Add sticky bottom audio queue panel: always-visible scrubber with timestamps,
  expandable rows for Now/Next/Dbg with color-coded state badges
- Fix iOS Reader Mode: header->nav aria-hidden, audio outside nav, role=main,
  visible h1 in content area, hover-only paragraph highlight
- Fix home screen Available books hidden: stop hiding cards in #books-grid
  when they appear in Continue Reading; Available always shows all books
This commit is contained in:
Admin
2026-03-01 20:44:03 +05:00
parent 79f5edf80c
commit 81e5d015b4
3 changed files with 641 additions and 503 deletions

View File

@@ -408,6 +408,26 @@ func (w *Writer) bookDir(slug string) string {
return filepath.Join(w.root, slug)
}
// AudioDir returns the directory used to cache generated MP3 files for a book.
func (w *Writer) AudioDir(slug string) string {
return filepath.Join(w.bookDir(slug), "audio")
}
// AudioPath returns the full path for a cached chapter audio file.
// The filename is keyed by chapter number, voice, and speed so that different
// settings never collide. Speed is formatted to one decimal place (e.g. "1.0").
func (w *Writer) AudioPath(slug string, n int, voice string, speed float64) string {
// Sanitise voice so it is safe as a filename component.
safeVoice := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, voice)
filename := fmt.Sprintf("ch%d-%s-%.1f.mp3", n, safeVoice, speed)
return filepath.Join(w.AudioDir(slug), filename)
}
// chapterPath computes the full file path for a chapter.
//
// vol-{volume}/{folderRange}/chapter-{number}.md