Fix summary overlay scrolling and paragraph formatting on mobile
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

- Make overlay scroll within the viewport instead of being clipped (overflow-y-auto on backdrop, items-start alignment)
- Split summary text into paragraphs in the zoom overlay (newline-split with sentence-boundary fallback)
- Add Summary heading in overlay for context
- Remove old merge-poller JS; simplify generateAudio to use data.url directly (Kokoro backend rewrite cleanup)
This commit is contained in:
Admin
2026-03-01 23:37:05 +05:00
parent 13a3eacca6
commit db59d55f42
2 changed files with 390 additions and 601 deletions

View File

@@ -15,7 +15,6 @@ import (
"io"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"sync"
@@ -39,12 +38,13 @@ type Server struct {
kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880
kokoroVoice string // default voice, e.g. af_bella
// audioMu guards audioInFlight.
// audioInFlight maps an audio cache key to a channel that is closed when
// the in-flight Kokoro request for that key finishes (successfully or not).
// This prevents duplicate concurrent TTS generation for the same file.
// audioMu guards audioCache and audioInFlight.
// audioCache maps a cache key to the Kokoro download filename returned by
// POST /v1/audio/speech with return_download_link=true.
// audioInFlight deduplicates concurrent generation requests for the same key.
audioMu sync.Mutex
audioInFlight map[string]chan struct{}
audioCache map[string]string // cacheKey → kokoro download filename
audioInFlight map[string]chan struct{} // cacheKey → closed when done
}
// New creates a new Server.
@@ -57,6 +57,7 @@ func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log
writer: writer.New(oCfg.StaticRoot),
kokoroURL: kokoroURL,
kokoroVoice: kokoroVoice,
audioCache: make(map[string]string),
audioInFlight: make(map[string]chan struct{}),
}
}
@@ -82,18 +83,16 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /ui/ranking/status", s.handleRankingStatus)
// Plain-text chapter content for browser-side TTS
mux.HandleFunc("GET /ui/chapter-text/{slug}/{n}", s.handleChapterText)
// Server-side audio generation and serving.
// Audio generation can take several minutes for long chapters, so wrap it
// in its own timeout handler instead of relying on the server WriteTimeout.
// Server-side audio generation via Kokoro /v1/audio/speech.
// Generation can take several minutes, so wrap in its own timeout handler.
audioGenHandler := http.TimeoutHandler(
http.HandlerFunc(s.handleAudioGenerate),
10*time.Minute,
`{"error":"audio generation timed out"}`,
)
mux.Handle("POST /ui/audio/{slug}/{n}", audioGenHandler)
mux.HandleFunc("GET /ui/audio/{slug}/{n}/status", s.handleAudioStatus)
mux.HandleFunc("GET /ui/audio-file/{slug}/{n}", s.handleAudioFile)
mux.HandleFunc("GET /ui/audio-file/{slug}/{n}/part/{p}", s.handleAudioFilePart)
// Proxy route: fetches the generated file from Kokoro /v1/download/{filename}.
mux.HandleFunc("GET /ui/audio-proxy/{slug}/{n}", s.handleAudioProxy)
srv := &http.Server{
Addr: s.addr,
@@ -142,25 +141,18 @@ func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, stripMarkdown(raw))
}
// ─── Chunked audio generation ────────────────────────────────────────────────
// ─── Audio generation via Kokoro /v1/audio/speech ────────────────────────────
//
// handleAudioGenerate handles POST /ui/audio/{slug}/{n}.
//
// Flow:
// 1. If the merged MP3 already exists on disk → return it immediately.
// 2. Otherwise split the chapter text into up to audioParts equal-ish chunks
// (by paragraph), generate part 0 synchronously (so the browser can start
// playing right away), then launch a background goroutine that generates
// parts 1…N and merges them into the final file.
// 3. Response: {"url":"<part-0-url>","parts":N,"merged":false}
// (or {"url":"<final-url>","parts":1,"merged":true} on a cache hit).
// It calls Kokoro's POST /v1/audio/speech with return_download_link=true.
// Kokoro generates the audio, saves it to its own temp storage, and returns
// the download filename in the X-Download-Path response header.
// We cache that filename (in memory, keyed by slug/chapter/voice/speed) and
// return a proxy URL that the browser sets as audio.src.
//
// Deduplication: if another request is already generating the *merged* file
// for the same (slug,n,voice,speed) key, the new request blocks until it
// finishes and then serves the cached result.
const audioParts = 10
// handleAudioGenerate handles POST /ui/audio/{slug}/{n}.
// On a cache hit the proxy URL is returned immediately without re-generating.
// Concurrent requests for the same key are deduplicated.
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
@@ -186,18 +178,17 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
speed = body.Speed
}
audioPath := s.writer.AudioPath(slug, n, voice, speed)
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
// Fast path: merged file already on disk.
if _, err := os.Stat(audioPath); err == nil {
s.writeAudioResponse(w, slug, n, voice, speed, 1, true)
// Fast path: already generated this session.
s.audioMu.Lock()
if filename, ok := s.audioCache[cacheKey]; ok {
s.audioMu.Unlock()
s.writeAudioResponse(w, slug, n, voice, speed, filename)
return
}
// Deduplicate concurrent generation requests for the same merged file.
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
s.audioMu.Lock()
// Deduplicate concurrent generation for the same key.
if ch, ok := s.audioInFlight[cacheKey]; ok {
s.audioMu.Unlock()
select {
@@ -206,8 +197,11 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable)
return
}
if _, err := os.Stat(audioPath); err == nil {
s.writeAudioResponse(w, slug, n, voice, speed, 1, true)
s.audioMu.Lock()
filename, ok := s.audioCache[cacheKey]
s.audioMu.Unlock()
if ok {
s.writeAudioResponse(w, slug, n, voice, speed, filename)
} else {
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
}
@@ -240,307 +234,141 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
return
}
// Ensure audio dir exists.
if err := os.MkdirAll(s.writer.AudioDir(slug), 0o755); err != nil {
http.Error(w, `{"error":"failed to create audio dir"}`, http.StatusInternalServerError)
// Call Kokoro POST /v1/audio/speech with return_download_link=true.
// Kokoro saves the generated audio to its own temp storage and returns the
// download path in the X-Download-Path response header.
filename, err := s.generateSpeech(r.Context(), text, voice, speed)
if err != nil {
s.log.Error("kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
http.Error(w, `{"error":"speech generation failed"}`, http.StatusBadGateway)
return
}
parts := splitTextIntoParts(text, audioParts)
totalParts := len(parts)
s.audioMu.Lock()
s.audioCache[cacheKey] = filename
s.audioMu.Unlock()
// Generate part 0 synchronously so the browser can start playing immediately.
if err := s.generateAudioPart(r.Context(), slug, n, voice, speed, 0, parts[0]); err != nil {
s.log.Error("part 0 generation failed", "err", err)
http.Error(w, `{"error":"part 0 generation failed"}`, http.StatusBadGateway)
return
}
if totalParts == 1 {
// Only one part — rename it to the final path directly.
partPath := s.writer.AudioPartPath(slug, n, voice, speed, 0)
if err := os.Rename(partPath, audioPath); err != nil {
http.Error(w, `{"error":"failed to save audio"}`, http.StatusInternalServerError)
return
}
s.log.Info("audio generated (single part)", "slug", slug, "chapter", n)
s.writeAudioResponse(w, slug, n, voice, speed, 1, true)
return
}
// Return part-0 URL immediately; generate the rest in the background.
s.writeAudioResponse(w, slug, n, voice, speed, totalParts, false)
// Background: generate parts 1…N then merge.
go func() {
bgCtx := context.Background()
for p := 1; p < totalParts; p++ {
if err := s.generateAudioPart(bgCtx, slug, n, voice, speed, p, parts[p]); err != nil {
s.log.Error("background part generation failed", "part", p, "err", err)
return
}
}
if err := s.mergeAudioParts(slug, n, voice, speed, totalParts); err != nil {
s.log.Error("audio merge failed", "slug", slug, "chapter", n, "err", err)
return
}
s.log.Info("audio merged", "slug", slug, "chapter", n, "parts", totalParts)
}()
s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
s.writeAudioResponse(w, slug, n, voice, speed, filename)
}
// splitTextIntoParts divides text (paragraphs separated by blank lines) into
// at most n equal-ish chunks. Returns at least 1 element.
func splitTextIntoParts(text string, n int) []string {
// Split into paragraphs on blank lines.
raw := strings.Split(text, "\n\n")
var paras []string
for _, p := range raw {
p = strings.TrimSpace(p)
if p != "" {
paras = append(paras, p)
}
}
if len(paras) == 0 {
return []string{text}
}
if n > len(paras) {
n = len(paras)
}
if n < 1 {
n = 1
}
chunks := make([]string, n)
chunkSize := (len(paras) + n - 1) / n // ceiling division
for i := 0; i < n; i++ {
start := i * chunkSize
end := start + chunkSize
if start >= len(paras) {
// Fewer paragraphs than requested parts: return what we have.
chunks = chunks[:i]
break
}
if end > len(paras) {
end = len(paras)
}
chunks[i] = strings.Join(paras[start:end], "\n\n")
}
if len(chunks) == 0 {
return []string{text}
}
return chunks
}
// generateAudioPart calls Kokoro for a single text chunk and writes the result
// atomically to AudioPartPath(…, part).
func (s *Server) generateAudioPart(ctx context.Context, slug string, n int, voice string, speed float64, part int, text string) error {
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
// and returns the filename from the X-Download-Path response header.
func (s *Server) generateSpeech(ctx context.Context, text, voice string, speed float64) (string, error) {
reqBody, _ := json.Marshal(map[string]interface{}{
"model": "kokoro",
"input": text,
"voice": voice,
"response_format": "mp3",
"speed": speed,
"stream": false,
"model": "kokoro",
"input": text,
"voice": voice,
"response_format": "mp3",
"speed": speed,
"stream": false,
"return_download_link": true,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
s.kokoroURL+"/v1/audio/speech", bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("build request: %w", err)
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("kokoro request: %w", err)
return "", fmt.Errorf("kokoro request: %w", err)
}
defer resp.Body.Close()
// Drain body so the connection can be reused.
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("kokoro status %d", resp.StatusCode)
}
// X-Download-Path is e.g. "/download/speech_abc123.mp3"
dlPath := resp.Header.Get("X-Download-Path")
if dlPath == "" {
return "", fmt.Errorf("kokoro did not return X-Download-Path header")
}
// Extract just the filename from the path.
filename := dlPath
if idx := strings.LastIndex(dlPath, "/"); idx >= 0 {
filename = dlPath[idx+1:]
}
if filename == "" {
return "", fmt.Errorf("empty filename in X-Download-Path: %q", dlPath)
}
return filename, nil
}
// writeAudioResponse writes the JSON response for a generated audio chapter.
// The URL points to our proxy handler which fetches from Kokoro on demand.
func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, speed float64, filename string) {
proxyURL := fmt.Sprintf("/ui/audio-proxy/%s/%d?voice=%s&speed=%.1f", slug, n, voice, speed)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"url": proxyURL,
"filename": filename,
})
}
// handleAudioProxy handles GET /ui/audio-proxy/{slug}/{n}.
// It looks up the Kokoro download filename for this chapter (voice/speed) and
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
speedStr := r.URL.Query().Get("speed")
speed := 1.0
if speedStr != "" {
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
speed = v
}
}
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
s.audioMu.Lock()
filename, ok := s.audioCache[cacheKey]
s.audioMu.Unlock()
if !ok {
http.Error(w, "audio not generated yet", http.StatusNotFound)
return
}
kokoroURL := s.kokoroURL + "/v1/download/" + filename
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, kokoroURL, nil)
if err != nil {
http.Error(w, "failed to build proxy request", http.StatusInternalServerError)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "kokoro download failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("kokoro status %d: %s", resp.StatusCode, string(body))
}
partPath := s.writer.AudioPartPath(slug, n, voice, speed, part)
tmpPath := partPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("write audio: %w", err)
}
f.Close()
if err := os.Rename(tmpPath, partPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("rename temp file: %w", err)
}
return nil
}
// mergeAudioParts concatenates totalParts part files in order into AudioPath,
// then removes the individual part files.
func (s *Server) mergeAudioParts(slug string, n int, voice string, speed float64, totalParts int) error {
audioPath := s.writer.AudioPath(slug, n, voice, speed)
tmpPath := audioPath + ".tmp"
out, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("create merged temp: %w", err)
}
for p := 0; p < totalParts; p++ {
partPath := s.writer.AudioPartPath(slug, n, voice, speed, p)
data, err := os.ReadFile(partPath)
if err != nil {
out.Close()
os.Remove(tmpPath)
return fmt.Errorf("read part %d: %w", p, err)
}
if _, err := out.Write(data); err != nil {
out.Close()
os.Remove(tmpPath)
return fmt.Errorf("write merged part %d: %w", p, err)
}
}
out.Close()
if err := os.Rename(tmpPath, audioPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("rename merged: %w", err)
}
// Clean up part files (best-effort).
for p := 0; p < totalParts; p++ {
os.Remove(s.writer.AudioPartPath(slug, n, voice, speed, p))
}
return nil
}
func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, speed float64, parts int, merged bool) {
var url string
if merged {
url = fmt.Sprintf("/ui/audio-file/%s/%d?voice=%s&speed=%.1f", slug, n, voice, speed)
} else {
url = fmt.Sprintf("/ui/audio-file/%s/%d/part/0?voice=%s&speed=%.1f", slug, n, voice, speed)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"url": url,
"parts": parts,
"merged": merged,
})
}
// writeAudioURL is kept for backward compatibility (used by dedup waiters).
func (s *Server) writeAudioURL(w http.ResponseWriter, slug string, n int, voice string, speed float64) {
s.writeAudioResponse(w, slug, n, voice, speed, 1, true)
}
// handleAudioStatus handles GET /ui/audio/{slug}/{n}/status.
// Returns {"merged":true/false,"url":"..."} so the browser can poll for the
// merged file after receiving a parts response from handleAudioGenerate.
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
speedStr := r.URL.Query().Get("speed")
speed := 1.0
if speedStr != "" {
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
speed = v
}
}
audioPath := s.writer.AudioPath(slug, n, voice, speed)
merged := false
if _, err := os.Stat(audioPath); err == nil {
merged = true
}
mergedURL := fmt.Sprintf("/ui/audio-file/%s/%d?voice=%s&speed=%.1f", slug, n, voice, speed)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"merged": merged,
"url": mergedURL,
})
}
// handleAudioFile handles GET /ui/audio-file/{slug}/{n}.
// Serves the cached merged MP3 file identified by voice and speed query params.
func (s *Server) handleAudioFile(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
speedStr := r.URL.Query().Get("speed")
speed := 1.0
if speedStr != "" {
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
speed = v
}
}
audioPath := s.writer.AudioPath(slug, n, voice, speed)
if _, err := os.Stat(audioPath); err != nil {
http.NotFound(w, r)
http.Error(w, fmt.Sprintf("kokoro returned %d", resp.StatusCode), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "audio/mpeg")
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, audioPath)
}
// handleAudioFilePart handles GET /ui/audio-file/{slug}/{n}/part/{p}.
// Serves a specific MP3 part file.
func (s *Server) handleAudioFilePart(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
w.Header().Set("Cache-Control", "public, max-age=3600")
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
p, err := strconv.Atoi(r.PathValue("p"))
if err != nil || p < 0 {
http.NotFound(w, r)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
speedStr := r.URL.Query().Get("speed")
speed := 1.0
if speedStr != "" {
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
speed = v
}
}
partPath := s.writer.AudioPartPath(slug, n, voice, speed, p)
if _, err := os.Stat(partPath); err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "audio/mpeg")
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, partPath)
_, _ = io.Copy(w, resp.Body)
}
func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) {

View File

@@ -1429,12 +1429,13 @@ const bookTmpl = `
<!-- Summary zoom overlay -->
<div id="summary-zoom-overlay"
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out p-6"
class="fixed inset-0 z-50 hidden items-start justify-center bg-black/80 cursor-zoom-out p-4 pt-8 overflow-y-auto"
onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');">
<div class="max-w-xl w-full bg-zinc-900 border border-zinc-700 rounded-2xl p-6 shadow-2xl cursor-default" onclick="event.stopPropagation()">
<p id="summary-zoom-text" class="text-zinc-200 text-base leading-relaxed whitespace-pre-wrap"></p>
<h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-4">Summary</h2>
<div id="summary-zoom-text" class="text-zinc-200 text-base leading-relaxed space-y-3"></div>
<button onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');"
class="mt-4 text-xs text-zinc-500 hover:text-zinc-300 transition-colors">Close</button>
class="mt-5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors">Close</button>
</div>
</div>
@@ -1475,7 +1476,7 @@ const bookTmpl = `
</div>
{{if .Meta.Summary}}
<p class="text-zinc-400 text-sm mt-3 line-clamp-3 cursor-zoom-in hover:text-zinc-300 transition-colors"
onclick="(function(t){var o=document.getElementById('summary-zoom-overlay');document.getElementById('summary-zoom-text').textContent=t;o.classList.remove('hidden');o.classList.add('flex');})(this.dataset.full)"
onclick="(function(t){var o=document.getElementById('summary-zoom-overlay');var c=document.getElementById('summary-zoom-text');c.innerHTML='';var paras=t.split(/\n+/).filter(function(s){return s.trim().length>0;});if(paras.length<=1){paras=t.split(/(?<=[.!?])\s{2,}|(?<=[.!?][\"'\u201d])\s+/).filter(function(s){return s.trim().length>0;});}paras.forEach(function(p){var el=document.createElement('p');el.textContent=p.trim();c.appendChild(el);});o.classList.remove('hidden');o.classList.add('flex');})(this.dataset.full)"
data-full="{{.Meta.Summary}}">{{.Meta.Summary}}</p>
{{end}}
</div>
@@ -1862,14 +1863,13 @@ const chapterTmpl = `
</a>
{{end}}
<!-- TTS play/pause -->
<!-- TTS play/pause (icon-only) -->
<button id="tts-btn"
type="button"
onclick="ttsToggle()"
aria-label="Listen"
class="flex-shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-transparent text-amber-500 hover:text-amber-400 text-[0.8125rem] font-medium border-none cursor-pointer transition-colors">
class="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-full bg-amber-500 hover:bg-amber-400 text-zinc-950 text-base border-none cursor-pointer transition-colors">
<span id="tts-icon" aria-hidden="true">&#9654;</span>
<span id="tts-label">Listen</span>
</button>
<!-- Settings -->
@@ -1982,55 +1982,89 @@ const chapterTmpl = `
</nav>
</main>
<!-- ─── Audio queue bar (sticky bottom) ──────────────────────────────────── -->
<aside id="audio-queue-bar"
aria-label="Audio queue"
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 border-t border-zinc-800 text-xs text-zinc-400">
<!-- ─── Mini audio player (slides up from bottom when audio starts) ────────── -->
<aside id="mini-player"
aria-label="Audio player"
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 border-t border-zinc-800 text-zinc-100 translate-y-full transition-transform duration-300 ease-out">
<!-- Toggle handle -->
<button id="queue-toggle"
type="button"
onclick="window.toggleQueuePanel()"
aria-expanded="false"
class="w-full flex items-center gap-2 px-4 py-1.5 bg-transparent border-none cursor-pointer text-zinc-400 text-[0.7rem] text-left">
<span class="flex-1 flex items-center gap-2">
<span id="queue-toggle-icon" aria-hidden="true" class="text-[0.65rem]">&#9650;</span>
<span class="font-semibold tracking-widest uppercase">Audio Queue</span>
<span id="queue-now-badge" class="queue-badge queue-badge-idle">idle</span>
</span>
<!-- Mini scrubber -->
<span class="flex-[2] flex items-center gap-2 px-2" aria-hidden="true">
<span id="queue-time-cur" class="min-w-[2.5rem] text-right tabular-nums">0:00</span>
<span class="flex-1 relative h-[3px] bg-zinc-700 rounded-sm overflow-hidden">
<span id="queue-scrubber-fill" class="absolute left-0 top-0 h-full w-0 bg-amber-500 transition-[width] duration-300 linear"></span>
</span>
<span id="queue-time-tot" class="min-w-[2.5rem] tabular-nums">0:00</span>
</span>
</button>
<!-- Collapsed bar: play/pause · title · time · expand -->
<div id="player-bar"
class="max-w-2xl mx-auto flex items-center gap-3 px-4 h-14">
<!-- Play / Pause button -->
<button id="player-play-btn"
type="button"
onclick="ttsToggle()"
aria-label="Play/Pause"
class="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-full bg-amber-500 hover:bg-amber-400 text-zinc-950 text-base border-none cursor-pointer transition-colors">
<span id="player-play-icon" aria-hidden="true">&#9654;</span>
</button>
<!-- Chapter title + inline mini progress -->
<div class="flex-1 min-w-0 flex flex-col justify-center gap-0.5">
<span id="player-title" class="text-[0.8rem] font-medium text-zinc-200 truncate"></span>
<!-- Tap-to-seek progress track -->
<div id="player-seek-track"
role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
class="relative w-full h-[4px] bg-zinc-700 rounded-full cursor-pointer">
<div id="player-seek-fill" class="absolute left-0 top-0 h-full bg-amber-500 rounded-full pointer-events-none" style="width:0%"></div>
</div>
</div>
<!-- Time display -->
<div class="flex-shrink-0 flex flex-col items-end text-[0.7rem] tabular-nums text-zinc-400">
<span id="player-time-cur">0:00</span>
<span id="player-time-tot" class="text-zinc-600">0:00</span>
</div>
<!-- Expand toggle -->
<button id="player-expand-btn"
type="button"
onclick="window.togglePlayerPanel()"
aria-label="Expand player"
aria-expanded="false"
class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-lg bg-transparent border-none cursor-pointer text-zinc-500 hover:text-zinc-200 transition-colors text-[0.75rem]">
<span id="player-expand-icon" aria-hidden="true">&#9650;</span>
</button>
</div>
<!-- Expanded panel -->
<div id="queue-panel-body" hidden class="border-t border-zinc-800">
<div id="player-panel" hidden
class="max-w-2xl mx-auto border-t border-zinc-800 px-4 py-3 flex flex-col gap-3">
<!-- Now playing -->
<div class="queue-row" id="queue-row-current">
<span class="queue-row-label">Now</span>
<span class="queue-row-title" id="queue-cur-title">—</span>
<span class="queue-badge" id="queue-cur-badge">idle</span>
<!-- Full seekable scrub bar -->
<div class="flex items-center gap-3">
<span id="player-full-cur" class="text-[0.7rem] tabular-nums text-zinc-400 min-w-[2.5rem] text-right">0:00</span>
<div id="player-full-track"
role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
class="flex-1 relative h-[6px] bg-zinc-700 rounded-full cursor-pointer">
<div id="player-full-fill" class="absolute left-0 top-0 h-full bg-amber-500 rounded-full pointer-events-none" style="width:0%"></div>
<div id="player-full-thumb" class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-amber-400 rounded-full shadow pointer-events-none" style="left:0%"></div>
</div>
<span id="player-full-tot" class="text-[0.7rem] tabular-nums text-zinc-400 min-w-[2.5rem]">0:00</span>
</div>
<!-- Up next -->
<div class="queue-row" id="queue-row-next" hidden>
<span class="queue-row-label">Next</span>
<span class="queue-row-title" id="queue-next-title">—</span>
<span class="queue-badge" id="queue-next-badge">—</span>
</div>
<!-- Controls row -->
<div class="flex items-center justify-between">
<!-- State badge -->
<span id="player-state-badge" class="queue-badge queue-badge-idle">idle</span>
<!-- Debug row -->
<div class="queue-row queue-row-debug" id="queue-row-debug">
<span class="queue-row-label">dbg</span>
<span id="queue-debug-text" class="font-mono break-all"></span>
</div>
<!-- Stop button -->
<button id="player-stop-btn"
type="button"
onclick="playerStop()"
aria-label="Stop"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-[0.8rem] border-none cursor-pointer transition-colors">
&#9632; Stop
</button>
<!-- Next chapter prefetch status -->
<div id="player-next-row" hidden class="flex items-center gap-2">
<span class="text-[0.7rem] text-zinc-600 uppercase tracking-wide font-semibold">Next</span>
<span id="player-next-title" class="text-[0.75rem] text-zinc-400 truncate max-w-[8rem]">—</span>
<span id="player-next-badge" class="queue-badge queue-badge-idle">—</span>
</div>
</div>
</div>
</aside>
@@ -2042,9 +2076,6 @@ const chapterTmpl = `
padding-left: 0.5rem;
transition: background 0.15s;
}
@media (hover: hover) {
#chapter-article p:hover { background: rgba(251,191,36,0.07); }
}
#chapter-article p.tts-active {
background: rgba(251,191,36,0.13);
border-left: 2px solid #f59e0b;
@@ -2052,11 +2083,11 @@ const chapterTmpl = `
}
/* Prevent sticky nav from obscuring anchor targets */
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 4rem; }
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 5rem; }
#chapter-article { font-size: 1.0625rem; line-height: 1.8; }
#chapter-article p + p { margin-top: 0; }
/* Audio queue badges */
/* Audio state badges */
.queue-badge {
display: inline-block;
padding: 0.1em 0.5em;
@@ -2074,31 +2105,18 @@ const chapterTmpl = `
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
.queue-badge-error { background: #450a0a; color: #f87171; }
/* Queue rows */
.queue-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 1rem;
border-bottom: 1px solid #18181b;
/* Mini-player slide-up */
#mini-player { will-change: transform; }
#mini-player.player-visible { transform: translateY(0); }
/* Seek track hit-area padding for easier touch */
#player-seek-track, #player-full-track {
padding-top: 6px;
padding-bottom: 6px;
margin-top: -6px;
margin-bottom: -6px;
box-sizing: content-box;
}
.queue-row-label {
width: 2.5rem;
flex-shrink: 0;
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #52525b;
}
.queue-row-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #d4d4d8;
}
.queue-row-debug { background: #0c0c0e; color: #52525b; font-size: 0.65rem; }
</style>
<script>
@@ -2108,7 +2126,7 @@ const chapterTmpl = `
var SLUG = '{{.Slug}}';
var CHAPTER_N = {{.ChapterN}};
// ── reading progress ─────────────────────────────────────────────────────────
// ── reading progress ─────────────────────────────────────────────────────────
(function saveProgress() {
try {
var p = JSON.parse(localStorage.getItem('reading_progress') || '{}');
@@ -2117,10 +2135,10 @@ const chapterTmpl = `
} catch(_) {}
})();
// ── DOM refs ─────────────────────────────────────────────────────────────────
var audio = document.getElementById('tts-audio');
var btn = document.getElementById('tts-btn');
var icon = document.getElementById('tts-icon');
var label = document.getElementById('tts-label');
var navBtn = document.getElementById('tts-btn'); // nav play/pause circle
var navIcon = document.getElementById('tts-icon');
var statusEl = document.getElementById('tts-status');
var statusBar = document.getElementById('tts-status-bar');
var voiceSel = document.getElementById('tts-voice');
@@ -2129,31 +2147,38 @@ const chapterTmpl = `
var autoplayChk = document.getElementById('tts-autoplay');
var article = document.getElementById('chapter-article');
// ── audio queue bar DOM refs ───────────────────────────────────────────────────
var queuePanelBody = document.getElementById('queue-panel-body');
var queueToggleBtn = document.getElementById('queue-toggle');
var queueToggleIcon = document.getElementById('queue-toggle-icon');
var queueNowBadge = document.getElementById('queue-now-badge');
var queueTimeCur = document.getElementById('queue-time-cur');
var queueTimeTot = document.getElementById('queue-time-tot');
var queueFill = document.getElementById('queue-scrubber-fill');
var queueRowNext = document.getElementById('queue-row-next');
var queueCurTitle = document.getElementById('queue-cur-title');
var queueCurBadge = document.getElementById('queue-cur-badge');
var queueNextTitle = document.getElementById('queue-next-title');
var queueNextBadge = document.getElementById('queue-next-badge');
var queueDebugText = document.getElementById('queue-debug-text');
// mini-player
var miniPlayer = document.getElementById('mini-player');
var playerPlayBtn = document.getElementById('player-play-btn');
var playerPlayIcon = document.getElementById('player-play-icon');
var playerTitle = document.getElementById('player-title');
var playerSeekTrack = document.getElementById('player-seek-track');
var playerSeekFill = document.getElementById('player-seek-fill');
var playerTimeCur = document.getElementById('player-time-cur');
var playerTimeTot = document.getElementById('player-time-tot');
var playerExpandBtn = document.getElementById('player-expand-btn');
var playerExpandIcon= document.getElementById('player-expand-icon');
var playerPanel = document.getElementById('player-panel');
var playerFullTrack = document.getElementById('player-full-track');
var playerFullFill = document.getElementById('player-full-fill');
var playerFullThumb = document.getElementById('player-full-thumb');
var playerFullCur = document.getElementById('player-full-cur');
var playerFullTot = document.getElementById('player-full-tot');
var playerStateBadge= document.getElementById('player-state-badge');
var playerNextRow = document.getElementById('player-next-row');
var playerNextTitle = document.getElementById('player-next-title');
var playerNextBadge = document.getElementById('player-next-badge');
// ── audio queue helpers ────────────────────────────────────────────────────────
// ── badge helper ─────────────────────────────────────────────────────────────
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
'queue-badge-playing','queue-badge-paused','queue-badge-error'];
function setBadge(el, state) {
BADGE_CLASSES.forEach(function (c) { el.classList.remove(c); });
el.classList.add('queue-badge-' + state);
el.textContent = state;
}
// ── time formatter ────────────────────────────────────────────────────────────
function fmtTime(secs) {
if (!isFinite(secs) || secs < 0) return '0:00';
var m = Math.floor(secs / 60);
@@ -2161,44 +2186,69 @@ const chapterTmpl = `
return m + ':' + (s < 10 ? '0' : '') + s;
}
function queueUpdateScrubber() {
// ── mini-player show/hide ─────────────────────────────────────────────────────
function showPlayer() { miniPlayer.classList.add('player-visible'); }
function hidePlayer() { miniPlayer.classList.remove('player-visible'); }
// ── scrubber update ───────────────────────────────────────────────────────────
function updateScrubber() {
var cur = audio.currentTime || 0;
var tot = audio.duration;
queueTimeCur.textContent = fmtTime(cur);
queueTimeTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
var pct = (isFinite(tot) && tot > 0) ? Math.min(100, (cur / tot) * 100) : 0;
queueFill.style.width = pct.toFixed(1) + '%';
var pctStr = pct.toFixed(1) + '%';
// collapsed bar
playerTimeCur.textContent = fmtTime(cur);
playerTimeTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
playerSeekFill.style.width = pctStr;
// expanded bar
playerFullCur.textContent = fmtTime(cur);
playerFullTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
playerFullFill.style.width = pctStr;
playerFullThumb.style.left = pctStr;
// aria
playerSeekTrack.setAttribute('aria-valuenow', Math.round(pct));
playerFullTrack.setAttribute('aria-valuenow', Math.round(pct));
}
function queueSetCurrent(state, debugMsg) {
setBadge(queueNowBadge, state);
setBadge(queueCurBadge, state);
queueCurTitle.textContent = 'Ch. ' + CHAPTER_N;
if (debugMsg !== undefined) {
queueDebugText.textContent = debugMsg;
}
queueUpdateScrubber();
}
function queueSetNext(state, debugMsg) {
if (!NEXT_N) { queueRowNext.style.display = 'none'; return; }
queueRowNext.style.display = '';
setBadge(queueNextBadge, state);
queueNextTitle.textContent = 'Ch. ' + NEXT_N;
if (debugMsg !== undefined) {
queueDebugText.textContent = debugMsg;
}
}
// Toggle expand/collapse.
window.toggleQueuePanel = function () {
var open = queuePanelBody.style.display !== 'none';
queuePanelBody.style.display = open ? 'none' : '';
queueToggleIcon.innerHTML = open ? '&#9650;' : '&#9660;';
queueToggleBtn.setAttribute('aria-expanded', String(!open));
// ── toggle expanded panel ─────────────────────────────────────────────────────
window.togglePlayerPanel = function () {
var open = !playerPanel.hidden;
playerPanel.hidden = open;
playerExpandIcon.innerHTML = open ? '&#9650;' : '&#9660;';
playerExpandBtn.setAttribute('aria-expanded', String(!open));
};
// ── panel toggles ─────────────────────────────────────────────────────────────
// ── seek on track click/tap ───────────────────────────────────────────────────
function seekFromEvent(track, e) {
var rect = track.getBoundingClientRect();
var clientX = e.touches ? e.touches[0].clientX : e.clientX;
var ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
if (audio.duration && isFinite(audio.duration)) {
audio.currentTime = ratio * audio.duration;
}
}
function attachSeek(track) {
var dragging = false;
track.addEventListener('mousedown', function (e) { dragging = true; seekFromEvent(track, e); e.preventDefault(); });
track.addEventListener('touchstart', function (e) { dragging = true; seekFromEvent(track, e); }, { passive: true });
document.addEventListener('mousemove', function (e) { if (dragging) seekFromEvent(track, e); });
document.addEventListener('touchmove', function (e) { if (dragging) seekFromEvent(track, e.touches ? e : e); }, { passive: true });
document.addEventListener('mouseup', function () { dragging = false; });
document.addEventListener('touchend', function () { dragging = false; });
track.addEventListener('click', function (e) { seekFromEvent(track, e); });
}
attachSeek(playerSeekTrack);
attachSeek(playerFullTrack);
// ── next-chapter prefetch display ────────────────────────────────────────────
function setNextState(state) {
if (!NEXT_N) { playerNextRow.hidden = true; return; }
playerNextRow.hidden = false;
playerNextTitle.textContent = 'Ch.\u00a0' + NEXT_N;
setBadge(playerNextBadge, state);
}
// ── panel toggles (chapter list / settings) ───────────────────────────────────
var settingsPanel = document.getElementById('settings-panel');
var chapterListPanel = document.getElementById('chapter-list-panel');
var chapterListBtn = document.getElementById('chapter-list-btn');
@@ -2237,15 +2287,15 @@ const chapterTmpl = `
}
});
// ── status strip ─────────────────────────────────────────────────────────────
// ── status strip ─────────────────────────────────────────────────────────────
function setStatus(text) {
statusEl.textContent = text;
statusBar.style.display = text ? '' : 'none';
}
// ── localStorage settings ─────────────────────────────────────────────────────
var LS_SPEED = 'tts_speed';
var LS_VOICE = 'tts_voice';
var LS_SPEED = 'tts_speed';
var LS_VOICE = 'tts_voice';
var LS_AUTONEXT = 'tts_autonext';
(function loadSettings() {
@@ -2266,26 +2316,20 @@ const chapterTmpl = `
speedSlider.addEventListener('input', function () {
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
localStorage.setItem(LS_SPEED, speedSlider.value);
prefetchFired = false; // voice/speed changed — invalidate prefetch
prefetchFired = false;
});
voiceSel.addEventListener('change', function () {
localStorage.setItem(LS_VOICE, voiceSel.value);
prefetchFired = false; // voice/speed changed — invalidate prefetch
prefetchFired = false;
});
autoplayChk.addEventListener('change', function () {
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
});
// ── paragraph indexing ───────────────────────────────────────────────────────
// Clicking a paragraph seeks to the proportional position in the audio file.
// ── paragraph indexing (highlight only, no click-to-seek) ───────────────────
var paras = Array.prototype.slice.call(article.querySelectorAll('p'));
var activePara = null;
paras.forEach(function (p, i) {
p.dataset.paraIdx = i;
p.addEventListener('click', function () { seekToPara(i); });
});
function highlightPara(idx) {
if (activePara) activePara.classList.remove('tts-active');
activePara = (idx >= 0 && idx < paras.length) ? paras[idx] : null;
@@ -2295,104 +2339,74 @@ const chapterTmpl = `
}
}
// Seek to the proportional position in the loaded audio corresponding to para idx.
function seekToPara(idx) {
if (!audio.src || !audio.duration || !isFinite(audio.duration)) {
// Audio not loaded yet — generate it first, then seek once ready.
generateAudio(CHAPTER_N, function (url) {
audio.src = url;
audio.addEventListener('loadedmetadata', function onMeta() {
audio.removeEventListener('loadedmetadata', onMeta);
doSeek(idx);
});
audio.load();
});
return;
}
doSeek(idx);
}
function doSeek(idx) {
if (!audio.duration || !isFinite(audio.duration)) return;
var ratio = paras.length > 1 ? idx / (paras.length - 1) : 0;
audio.currentTime = ratio * audio.duration;
highlightPara(idx);
if (audio.paused) audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
}
// ── UI state helpers ──────────────────────────────────────────────────────────
function setPlayIcon(icon) {
navIcon.innerHTML = icon;
playerPlayIcon.innerHTML = icon;
}
function setGenerating() {
icon.textContent = '\u231B';
label.textContent = 'Generating\u2026';
setStatus('Generating audio on server\u2026');
btn.disabled = true;
btn.style.opacity = '0.6';
btn.style.cursor = 'not-allowed';
setPlayIcon('\u231B');
navBtn.disabled = true;
navBtn.style.opacity = '0.6';
playerPlayBtn.disabled = true;
voiceSel.disabled = true;
speedSlider.disabled = true;
queueSetCurrent('generating', 'POST /ui/audio/' + SLUG + '/' + CHAPTER_N);
setBadge(playerStateBadge, 'generating');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N + '\u00a0— generating\u2026';
setStatus('Generating audio\u2026');
showPlayer();
}
function setPlaying() {
icon.innerHTML = '&#9646;&#9646;';
label.textContent = 'Pause';
setStatus('Playing');
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
setPlayIcon('&#9646;&#9646;');
navBtn.disabled = false;
navBtn.style.opacity = '1';
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
speedSlider.disabled = false;
queueSetCurrent('playing', 'audio.src set, playing ch.' + CHAPTER_N);
setBadge(playerStateBadge, 'playing');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('');
showPlayer();
}
function setPaused() {
icon.innerHTML = '&#9654;';
label.textContent = 'Resume';
setStatus('Paused');
queueSetCurrent('paused');
setPlayIcon('&#9654;');
setBadge(playerStateBadge, 'paused');
setStatus('');
}
function setStopped() {
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
setStatus('');
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
setPlayIcon('&#9654;');
navBtn.disabled = false;
navBtn.style.opacity = '1';
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
speedSlider.disabled = false;
queueSetCurrent('idle', 'stopped');
queueSetNext('idle');
queueUpdateScrubber();
setBadge(playerStateBadge, 'idle');
playerTitle.textContent = '—';
setStatus('');
updateScrubber();
hidePlayer();
}
function setError(msg) {
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
setStatus('Error: ' + msg);
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
setPlayIcon('&#9654;');
navBtn.disabled = false;
navBtn.style.opacity = '1';
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
speedSlider.disabled = false;
queueSetCurrent('error', 'err: ' + msg);
setBadge(playerStateBadge, 'error');
playerTitle.textContent = 'Error';
setStatus('Error: ' + msg);
showPlayer();
}
// ── server-side audio generation ─────────────────────────────────────────────
// POST /ui/audio/{slug}/{n} — returns {url, parts, merged}.
// When merged=false, url is part-0; polling /ui/audio/{slug}/{n}/status
// detects when the full merged file is ready and swaps audio.src seamlessly.
// cb(url) is called with the initial (part-0 or merged) URL on success.
var currentAudioCtrl = null; // AbortController for the active generateAudio fetch
var mergePoller = null; // setInterval id for polling merged status
function clearMergePoller() {
if (mergePoller !== null) { clearInterval(mergePoller); mergePoller = null; }
}
var currentAudioCtrl = null;
function generateAudio(chapterN, cb) {
// Cancel any previous in-flight generation and polling.
if (currentAudioCtrl) { currentAudioCtrl.abort(); }
clearMergePoller();
var ctrl = new AbortController();
currentAudioCtrl = ctrl;
@@ -2412,75 +2426,42 @@ const chapterTmpl = `
.then(function (data) {
if (!data || !data.url) throw new Error('no url in response');
cb(data.url);
// If the server is still generating remaining parts, start polling.
if (!data.merged && data.parts > 1) {
var statusURL = '/ui/audio/' + SLUG + '/' + chapterN + '/status'
+ '?voice=' + encodeURIComponent(voiceSel.value)
+ '&speed=' + parseFloat(speedSlider.value);
mergePoller = setInterval(function () {
fetch(statusURL)
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (s) {
if (s.merged) {
clearMergePoller();
// Seamlessly swap to the full merged file.
var ratio = (audio.duration && isFinite(audio.duration))
? audio.currentTime / audio.duration : 0;
audio.addEventListener('loadedmetadata', function onMeta() {
audio.removeEventListener('loadedmetadata', onMeta);
if (audio.duration && isFinite(audio.duration)) {
audio.currentTime = ratio * audio.duration;
}
}, { once: true });
audio.src = s.url;
audio.load();
}
})
.catch(function () { /* ignore transient poll errors */ });
}, 3000);
}
})
.catch(function (e) {
if (e.name === 'AbortError') return; // silently cancelled
if (e.name === 'AbortError') return;
setError(e.message);
});
}
// ── next-chapter prefetch ─────────────────────────────────────────────────────
// At 80 % playback we silently POST to generate the next chapter's audio so
// it is cached by the time the current chapter ends.
var prefetchFired = false; // ensure we only fire once per chapter
// ── next-chapter prefetch at 80% ─────────────────────────────────────────────
var prefetchFired = false;
audio.addEventListener('timeupdate', function () {
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) return;
if (audio.currentTime / audio.duration >= 0.8) {
prefetchFired = true;
queueSetNext('generating', 'POST /ui/audio/' + SLUG + '/' + NEXT_N + ' (prefetch)');
// Fire-and-forget: idempotent on server.
setNextState('generating');
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
voice: voiceSel.value,
speed: parseFloat(speedSlider.value)
})
body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) })
})
.then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); })
.then(function () { queueSetNext('ready', 'prefetch complete: ch.' + NEXT_N + ' cached'); })
.catch(function (e) { queueSetNext('error', 'prefetch failed: ' + e); });
.then(function () { setNextState('ready'); })
.catch(function () { setNextState('error'); });
}
});
// ── stop / cleanup ────────────────────────────────────────────────────────────
function stop() {
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
clearMergePoller();
audio.pause();
audio.src = '';
prefetchFired = false;
setStopped();
}
// Exposed for the Stop button in the expanded panel.
window.playerStop = stop;
// ── audio events ──────────────────────────────────────────────────────────────
audio.addEventListener('canplay', function () {
@@ -2494,9 +2475,8 @@ const chapterTmpl = `
audio.addEventListener('play', setPlaying);
audio.addEventListener('error', function () { setError('audio error'); });
// Sync paragraph highlight and scrubber while playing (via timeupdate).
audio.addEventListener('timeupdate', function () {
queueUpdateScrubber();
updateScrubber();
if (!audio.duration || !isFinite(audio.duration) || paras.length === 0) return;
var idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * paras.length),
@@ -2533,12 +2513,10 @@ const chapterTmpl = `
generateAudio(CHAPTER_N, function (url) {
audio.src = url;
audio.load();
// canplay event triggers play(); setPlaying() re-enables controls.
});
}
window.ttsToggle = function () {
// Already have audio loaded — just play/pause.
if (audio.src) {
if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
@@ -2559,51 +2537,34 @@ const chapterTmpl = `
setTimeout(startAudio, 100);
}
// ── initialise queue bar on page load ────────────────────────────────────────
queueSetCurrent('idle', 'ready — ch.' + CHAPTER_N);
if (NEXT_N) { queueSetNext('idle', 'waiting — ch.' + NEXT_N); }
// ── init next-chapter display ────────────────────────────────────────────────
if (NEXT_N) { setNextState('idle'); }
// ── double-tap left/right to navigate chapters ───────────────────────────────
// A double-tap on the left third of the screen goes to the previous chapter;
// a double-tap on the right third goes to the next chapter.
// Taps on interactive elements (buttons, links, inputs, selects) are ignored
// so normal UI interactions are never swallowed.
(function initDoubleTap() {
var lastTap = 0;
var lastSide = ''; // 'left' | 'right'
var THRESHOLD = 300; // ms between taps
var lastSide = '';
var THRESHOLD = 300;
var INTERACTIVE = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'];
function navigate(url) {
window.location.href = url;
}
function navigate(url) { window.location.href = url; }
document.addEventListener('touchend', function (e) {
// Ignore taps that land on interactive elements
var el = e.target;
while (el && el !== document.body) {
if (INTERACTIVE.indexOf(el.tagName) !== -1) return;
el = el.parentElement;
}
var touch = e.changedTouches[0];
var side = touch.clientX < window.innerWidth / 2 ? 'left' : 'right';
var now = Date.now();
var gap = now - lastTap;
var touch = e.changedTouches[0];
var side = touch.clientX < window.innerWidth / 2 ? 'left' : 'right';
var now = Date.now();
var gap = now - lastTap;
if (gap < THRESHOLD && side === lastSide) {
// Double-tap detected
lastTap = 0;
lastSide = '';
if (side === 'right' && NEXT_N) {
navigate('/books/' + SLUG + '/chapters/' + NEXT_N);
} else if (side === 'left' && PREV_N) {
navigate('/books/' + SLUG + '/chapters/' + PREV_N);
}
lastTap = 0; lastSide = '';
if (side === 'right' && NEXT_N) navigate('/books/' + SLUG + '/chapters/' + NEXT_N);
else if (side === 'left' && PREV_N) navigate('/books/' + SLUG + '/chapters/' + PREV_N);
} else {
lastTap = now;
lastSide = side;
lastTap = now; lastSide = side;
}
}, { passive: true });
}());