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" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -39,12 +38,13 @@ type Server struct {
kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880 kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880
kokoroVoice string // default voice, e.g. af_bella kokoroVoice string // default voice, e.g. af_bella
// audioMu guards audioInFlight. // audioMu guards audioCache and audioInFlight.
// audioInFlight maps an audio cache key to a channel that is closed when // audioCache maps a cache key to the Kokoro download filename returned by
// the in-flight Kokoro request for that key finishes (successfully or not). // POST /v1/audio/speech with return_download_link=true.
// This prevents duplicate concurrent TTS generation for the same file. // audioInFlight deduplicates concurrent generation requests for the same key.
audioMu sync.Mutex 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. // 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), writer: writer.New(oCfg.StaticRoot),
kokoroURL: kokoroURL, kokoroURL: kokoroURL,
kokoroVoice: kokoroVoice, kokoroVoice: kokoroVoice,
audioCache: make(map[string]string),
audioInFlight: make(map[string]chan struct{}), 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) mux.HandleFunc("GET /ui/ranking/status", s.handleRankingStatus)
// Plain-text chapter content for browser-side TTS // Plain-text chapter content for browser-side TTS
mux.HandleFunc("GET /ui/chapter-text/{slug}/{n}", s.handleChapterText) mux.HandleFunc("GET /ui/chapter-text/{slug}/{n}", s.handleChapterText)
// Server-side audio generation and serving. // Server-side audio generation via Kokoro /v1/audio/speech.
// Audio generation can take several minutes for long chapters, so wrap it // Generation can take several minutes, so wrap in its own timeout handler.
// in its own timeout handler instead of relying on the server WriteTimeout.
audioGenHandler := http.TimeoutHandler( audioGenHandler := http.TimeoutHandler(
http.HandlerFunc(s.handleAudioGenerate), http.HandlerFunc(s.handleAudioGenerate),
10*time.Minute, 10*time.Minute,
`{"error":"audio generation timed out"}`, `{"error":"audio generation timed out"}`,
) )
mux.Handle("POST /ui/audio/{slug}/{n}", audioGenHandler) mux.Handle("POST /ui/audio/{slug}/{n}", audioGenHandler)
mux.HandleFunc("GET /ui/audio/{slug}/{n}/status", s.handleAudioStatus) // Proxy route: fetches the generated file from Kokoro /v1/download/{filename}.
mux.HandleFunc("GET /ui/audio-file/{slug}/{n}", s.handleAudioFile) mux.HandleFunc("GET /ui/audio-proxy/{slug}/{n}", s.handleAudioProxy)
mux.HandleFunc("GET /ui/audio-file/{slug}/{n}/part/{p}", s.handleAudioFilePart)
srv := &http.Server{ srv := &http.Server{
Addr: s.addr, Addr: s.addr,
@@ -142,25 +141,18 @@ func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, stripMarkdown(raw)) fmt.Fprint(w, stripMarkdown(raw))
} }
// ─── Chunked audio generation ──────────────────────────────────────────────── // ─── Audio generation via Kokoro /v1/audio/speech ────────────────────────────
// //
// handleAudioGenerate handles POST /ui/audio/{slug}/{n}. // handleAudioGenerate handles POST /ui/audio/{slug}/{n}.
// //
// Flow: // It calls Kokoro's POST /v1/audio/speech with return_download_link=true.
// 1. If the merged MP3 already exists on disk → return it immediately. // Kokoro generates the audio, saves it to its own temp storage, and returns
// 2. Otherwise split the chapter text into up to audioParts equal-ish chunks // the download filename in the X-Download-Path response header.
// (by paragraph), generate part 0 synchronously (so the browser can start // We cache that filename (in memory, keyed by slug/chapter/voice/speed) and
// playing right away), then launch a background goroutine that generates // return a proxy URL that the browser sets as audio.src.
// 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).
// //
// Deduplication: if another request is already generating the *merged* file // On a cache hit the proxy URL is returned immediately without re-generating.
// for the same (slug,n,voice,speed) key, the new request blocks until it // Concurrent requests for the same key are deduplicated.
// finishes and then serves the cached result.
const audioParts = 10
// handleAudioGenerate handles POST /ui/audio/{slug}/{n}.
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug") slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n")) n, err := strconv.Atoi(r.PathValue("n"))
@@ -186,18 +178,17 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
speed = body.Speed 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. // Fast path: already generated this session.
if _, err := os.Stat(audioPath); err == nil { s.audioMu.Lock()
s.writeAudioResponse(w, slug, n, voice, speed, 1, true) if filename, ok := s.audioCache[cacheKey]; ok {
s.audioMu.Unlock()
s.writeAudioResponse(w, slug, n, voice, speed, filename)
return return
} }
// Deduplicate concurrent generation requests for the same merged file. // Deduplicate concurrent generation for the same key.
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
s.audioMu.Lock()
if ch, ok := s.audioInFlight[cacheKey]; ok { if ch, ok := s.audioInFlight[cacheKey]; ok {
s.audioMu.Unlock() s.audioMu.Unlock()
select { select {
@@ -206,8 +197,11 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable) http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable)
return return
} }
if _, err := os.Stat(audioPath); err == nil { s.audioMu.Lock()
s.writeAudioResponse(w, slug, n, voice, speed, 1, true) filename, ok := s.audioCache[cacheKey]
s.audioMu.Unlock()
if ok {
s.writeAudioResponse(w, slug, n, voice, speed, filename)
} else { } else {
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
} }
@@ -240,307 +234,141 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
return return
} }
// Ensure audio dir exists. // Call Kokoro POST /v1/audio/speech with return_download_link=true.
if err := os.MkdirAll(s.writer.AudioDir(slug), 0o755); err != nil { // Kokoro saves the generated audio to its own temp storage and returns the
http.Error(w, `{"error":"failed to create audio dir"}`, http.StatusInternalServerError) // 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 return
} }
parts := splitTextIntoParts(text, audioParts) s.audioMu.Lock()
totalParts := len(parts) s.audioCache[cacheKey] = filename
s.audioMu.Unlock()
// Generate part 0 synchronously so the browser can start playing immediately. s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
if err := s.generateAudioPart(r.Context(), slug, n, voice, speed, 0, parts[0]); err != nil { s.writeAudioResponse(w, slug, n, voice, speed, filename)
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)
}()
} }
// splitTextIntoParts divides text (paragraphs separated by blank lines) into // generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
// at most n equal-ish chunks. Returns at least 1 element. // and returns the filename from the X-Download-Path response header.
func splitTextIntoParts(text string, n int) []string { func (s *Server) generateSpeech(ctx context.Context, text, voice string, speed float64) (string, error) {
// 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 {
reqBody, _ := json.Marshal(map[string]interface{}{ reqBody, _ := json.Marshal(map[string]interface{}{
"model": "kokoro", "model": "kokoro",
"input": text, "input": text,
"voice": voice, "voice": voice,
"response_format": "mp3", "response_format": "mp3",
"speed": speed, "speed": speed,
"stream": false, "stream": false,
"return_download_link": true,
}) })
req, err := http.NewRequestWithContext(ctx, http.MethodPost, req, err := http.NewRequestWithContext(ctx, http.MethodPost,
s.kokoroURL+"/v1/audio/speech", bytes.NewReader(reqBody)) s.kokoroURL+"/v1/audio/speech", bytes.NewReader(reqBody))
if err != nil { if err != nil {
return fmt.Errorf("build request: %w", err) return "", fmt.Errorf("build request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) http.Error(w, fmt.Sprintf("kokoro returned %d", resp.StatusCode), http.StatusBadGateway)
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)
return return
} }
w.Header().Set("Content-Type", "audio/mpeg") w.Header().Set("Content-Type", "audio/mpeg")
w.Header().Set("Cache-Control", "public, max-age=86400") w.Header().Set("Cache-Control", "public, max-age=3600")
http.ServeFile(w, r, audioPath) if cl := resp.Header.Get("Content-Length"); cl != "" {
} w.Header().Set("Content-Length", cl)
// 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
} }
p, err := strconv.Atoi(r.PathValue("p")) _, _ = io.Copy(w, resp.Body)
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)
} }
func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) { func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) {

View File

@@ -1429,12 +1429,13 @@ const bookTmpl = `
<!-- Summary zoom overlay --> <!-- Summary zoom overlay -->
<div id="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');"> 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()"> <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');" <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>
</div> </div>
@@ -1475,7 +1476,7 @@ const bookTmpl = `
</div> </div>
{{if .Meta.Summary}} {{if .Meta.Summary}}
<p class="text-zinc-400 text-sm mt-3 line-clamp-3 cursor-zoom-in hover:text-zinc-300 transition-colors" <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> data-full="{{.Meta.Summary}}">{{.Meta.Summary}}</p>
{{end}} {{end}}
</div> </div>
@@ -1862,14 +1863,13 @@ const chapterTmpl = `
</a> </a>
{{end}} {{end}}
<!-- TTS play/pause --> <!-- TTS play/pause (icon-only) -->
<button id="tts-btn" <button id="tts-btn"
type="button" type="button"
onclick="ttsToggle()" onclick="ttsToggle()"
aria-label="Listen" 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-icon" aria-hidden="true">&#9654;</span>
<span id="tts-label">Listen</span>
</button> </button>
<!-- Settings --> <!-- Settings -->
@@ -1982,55 +1982,89 @@ const chapterTmpl = `
</nav> </nav>
</main> </main>
<!-- ─── Audio queue bar (sticky bottom) ──────────────────────────────────── --> <!-- ─── Mini audio player (slides up from bottom when audio starts) ────────── -->
<aside id="audio-queue-bar" <aside id="mini-player"
aria-label="Audio queue" aria-label="Audio player"
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 border-t border-zinc-800 text-xs text-zinc-400"> 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 --> <!-- Collapsed bar: play/pause · title · time · expand -->
<button id="queue-toggle" <div id="player-bar"
type="button" class="max-w-2xl mx-auto flex items-center gap-3 px-4 h-14">
onclick="window.toggleQueuePanel()"
aria-expanded="false" <!-- Play / Pause button -->
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"> <button id="player-play-btn"
<span class="flex-1 flex items-center gap-2"> type="button"
<span id="queue-toggle-icon" aria-hidden="true" class="text-[0.65rem]">&#9650;</span> onclick="ttsToggle()"
<span class="font-semibold tracking-widest uppercase">Audio Queue</span> aria-label="Play/Pause"
<span id="queue-now-badge" class="queue-badge queue-badge-idle">idle</span> 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> <span id="player-play-icon" aria-hidden="true">&#9654;</span>
<!-- Mini scrubber --> </button>
<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> <!-- Chapter title + inline mini progress -->
<span class="flex-1 relative h-[3px] bg-zinc-700 rounded-sm overflow-hidden"> <div class="flex-1 min-w-0 flex flex-col justify-center gap-0.5">
<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 id="player-title" class="text-[0.8rem] font-medium text-zinc-200 truncate"></span>
</span> <!-- Tap-to-seek progress track -->
<span id="queue-time-tot" class="min-w-[2.5rem] tabular-nums">0:00</span> <div id="player-seek-track"
</span> role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
</button> 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 --> <!-- 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 --> <!-- Full seekable scrub bar -->
<div class="queue-row" id="queue-row-current"> <div class="flex items-center gap-3">
<span class="queue-row-label">Now</span> <span id="player-full-cur" class="text-[0.7rem] tabular-nums text-zinc-400 min-w-[2.5rem] text-right">0:00</span>
<span class="queue-row-title" id="queue-cur-title">—</span> <div id="player-full-track"
<span class="queue-badge" id="queue-cur-badge">idle</span> 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> </div>
<!-- Up next --> <!-- Controls row -->
<div class="queue-row" id="queue-row-next" hidden> <div class="flex items-center justify-between">
<span class="queue-row-label">Next</span> <!-- State badge -->
<span class="queue-row-title" id="queue-next-title">—</span> <span id="player-state-badge" class="queue-badge queue-badge-idle">idle</span>
<span class="queue-badge" id="queue-next-badge">—</span>
</div>
<!-- Debug row --> <!-- Stop button -->
<div class="queue-row queue-row-debug" id="queue-row-debug"> <button id="player-stop-btn"
<span class="queue-row-label">dbg</span> type="button"
<span id="queue-debug-text" class="font-mono break-all"></span> onclick="playerStop()"
</div> 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> </div>
</aside> </aside>
@@ -2042,9 +2076,6 @@ const chapterTmpl = `
padding-left: 0.5rem; padding-left: 0.5rem;
transition: background 0.15s; transition: background 0.15s;
} }
@media (hover: hover) {
#chapter-article p:hover { background: rgba(251,191,36,0.07); }
}
#chapter-article p.tts-active { #chapter-article p.tts-active {
background: rgba(251,191,36,0.13); background: rgba(251,191,36,0.13);
border-left: 2px solid #f59e0b; border-left: 2px solid #f59e0b;
@@ -2052,11 +2083,11 @@ const chapterTmpl = `
} }
/* Prevent sticky nav from obscuring anchor targets */ /* 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 { font-size: 1.0625rem; line-height: 1.8; }
#chapter-article p + p { margin-top: 0; } #chapter-article p + p { margin-top: 0; }
/* Audio queue badges */ /* Audio state badges */
.queue-badge { .queue-badge {
display: inline-block; display: inline-block;
padding: 0.1em 0.5em; padding: 0.1em 0.5em;
@@ -2074,31 +2105,18 @@ const chapterTmpl = `
.queue-badge-paused { background: #1c1917; color: #d6d3d1; } .queue-badge-paused { background: #1c1917; color: #d6d3d1; }
.queue-badge-error { background: #450a0a; color: #f87171; } .queue-badge-error { background: #450a0a; color: #f87171; }
/* Queue rows */ /* Mini-player slide-up */
.queue-row { #mini-player { will-change: transform; }
display: flex; #mini-player.player-visible { transform: translateY(0); }
align-items: center;
gap: 0.5rem; /* Seek track hit-area padding for easier touch */
padding: 0.4rem 1rem; #player-seek-track, #player-full-track {
border-bottom: 1px solid #18181b; 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> </style>
<script> <script>
@@ -2108,7 +2126,7 @@ const chapterTmpl = `
var SLUG = '{{.Slug}}'; var SLUG = '{{.Slug}}';
var CHAPTER_N = {{.ChapterN}}; var CHAPTER_N = {{.ChapterN}};
// ── reading progress ───────────────────────────────────────────────────────── // ── reading progress ─────────────────────────────────────────────────────────
(function saveProgress() { (function saveProgress() {
try { try {
var p = JSON.parse(localStorage.getItem('reading_progress') || '{}'); var p = JSON.parse(localStorage.getItem('reading_progress') || '{}');
@@ -2117,10 +2135,10 @@ const chapterTmpl = `
} catch(_) {} } catch(_) {}
})(); })();
// ── DOM refs ─────────────────────────────────────────────────────────────────
var audio = document.getElementById('tts-audio'); var audio = document.getElementById('tts-audio');
var btn = document.getElementById('tts-btn'); var navBtn = document.getElementById('tts-btn'); // nav play/pause circle
var icon = document.getElementById('tts-icon'); var navIcon = document.getElementById('tts-icon');
var label = document.getElementById('tts-label');
var statusEl = document.getElementById('tts-status'); var statusEl = document.getElementById('tts-status');
var statusBar = document.getElementById('tts-status-bar'); var statusBar = document.getElementById('tts-status-bar');
var voiceSel = document.getElementById('tts-voice'); var voiceSel = document.getElementById('tts-voice');
@@ -2129,31 +2147,38 @@ const chapterTmpl = `
var autoplayChk = document.getElementById('tts-autoplay'); var autoplayChk = document.getElementById('tts-autoplay');
var article = document.getElementById('chapter-article'); var article = document.getElementById('chapter-article');
// ── audio queue bar DOM refs ─────────────────────────────────────────────────── // mini-player
var queuePanelBody = document.getElementById('queue-panel-body'); var miniPlayer = document.getElementById('mini-player');
var queueToggleBtn = document.getElementById('queue-toggle'); var playerPlayBtn = document.getElementById('player-play-btn');
var queueToggleIcon = document.getElementById('queue-toggle-icon'); var playerPlayIcon = document.getElementById('player-play-icon');
var queueNowBadge = document.getElementById('queue-now-badge'); var playerTitle = document.getElementById('player-title');
var queueTimeCur = document.getElementById('queue-time-cur'); var playerSeekTrack = document.getElementById('player-seek-track');
var queueTimeTot = document.getElementById('queue-time-tot'); var playerSeekFill = document.getElementById('player-seek-fill');
var queueFill = document.getElementById('queue-scrubber-fill'); var playerTimeCur = document.getElementById('player-time-cur');
var queueRowNext = document.getElementById('queue-row-next'); var playerTimeTot = document.getElementById('player-time-tot');
var queueCurTitle = document.getElementById('queue-cur-title'); var playerExpandBtn = document.getElementById('player-expand-btn');
var queueCurBadge = document.getElementById('queue-cur-badge'); var playerExpandIcon= document.getElementById('player-expand-icon');
var queueNextTitle = document.getElementById('queue-next-title'); var playerPanel = document.getElementById('player-panel');
var queueNextBadge = document.getElementById('queue-next-badge'); var playerFullTrack = document.getElementById('player-full-track');
var queueDebugText = document.getElementById('queue-debug-text'); 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', var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
'queue-badge-playing','queue-badge-paused','queue-badge-error']; 'queue-badge-playing','queue-badge-paused','queue-badge-error'];
function setBadge(el, state) { function setBadge(el, state) {
BADGE_CLASSES.forEach(function (c) { el.classList.remove(c); }); BADGE_CLASSES.forEach(function (c) { el.classList.remove(c); });
el.classList.add('queue-badge-' + state); el.classList.add('queue-badge-' + state);
el.textContent = state; el.textContent = state;
} }
// ── time formatter ────────────────────────────────────────────────────────────
function fmtTime(secs) { function fmtTime(secs) {
if (!isFinite(secs) || secs < 0) return '0:00'; if (!isFinite(secs) || secs < 0) return '0:00';
var m = Math.floor(secs / 60); var m = Math.floor(secs / 60);
@@ -2161,44 +2186,69 @@ const chapterTmpl = `
return m + ':' + (s < 10 ? '0' : '') + s; 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 cur = audio.currentTime || 0;
var tot = audio.duration; 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; 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) { // ── toggle expanded panel ─────────────────────────────────────────────────────
setBadge(queueNowBadge, state); window.togglePlayerPanel = function () {
setBadge(queueCurBadge, state); var open = !playerPanel.hidden;
queueCurTitle.textContent = 'Ch. ' + CHAPTER_N; playerPanel.hidden = open;
if (debugMsg !== undefined) { playerExpandIcon.innerHTML = open ? '&#9650;' : '&#9660;';
queueDebugText.textContent = debugMsg; playerExpandBtn.setAttribute('aria-expanded', String(!open));
}
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));
}; };
// ── 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 settingsPanel = document.getElementById('settings-panel');
var chapterListPanel = document.getElementById('chapter-list-panel'); var chapterListPanel = document.getElementById('chapter-list-panel');
var chapterListBtn = document.getElementById('chapter-list-btn'); var chapterListBtn = document.getElementById('chapter-list-btn');
@@ -2237,15 +2287,15 @@ const chapterTmpl = `
} }
}); });
// ── status strip ───────────────────────────────────────────────────────────── // ── status strip ─────────────────────────────────────────────────────────────
function setStatus(text) { function setStatus(text) {
statusEl.textContent = text; statusEl.textContent = text;
statusBar.style.display = text ? '' : 'none'; statusBar.style.display = text ? '' : 'none';
} }
// ── localStorage settings ───────────────────────────────────────────────────── // ── localStorage settings ─────────────────────────────────────────────────────
var LS_SPEED = 'tts_speed'; var LS_SPEED = 'tts_speed';
var LS_VOICE = 'tts_voice'; var LS_VOICE = 'tts_voice';
var LS_AUTONEXT = 'tts_autonext'; var LS_AUTONEXT = 'tts_autonext';
(function loadSettings() { (function loadSettings() {
@@ -2266,26 +2316,20 @@ const chapterTmpl = `
speedSlider.addEventListener('input', function () { speedSlider.addEventListener('input', function () {
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7'; speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
localStorage.setItem(LS_SPEED, speedSlider.value); localStorage.setItem(LS_SPEED, speedSlider.value);
prefetchFired = false; // voice/speed changed — invalidate prefetch prefetchFired = false;
}); });
voiceSel.addEventListener('change', function () { voiceSel.addEventListener('change', function () {
localStorage.setItem(LS_VOICE, voiceSel.value); localStorage.setItem(LS_VOICE, voiceSel.value);
prefetchFired = false; // voice/speed changed — invalidate prefetch prefetchFired = false;
}); });
autoplayChk.addEventListener('change', function () { autoplayChk.addEventListener('change', function () {
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false'); localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
}); });
// ── paragraph indexing ─────────────────────────────────────────────────────── // ── paragraph indexing (highlight only, no click-to-seek) ───────────────────
// Clicking a paragraph seeks to the proportional position in the audio file.
var paras = Array.prototype.slice.call(article.querySelectorAll('p')); var paras = Array.prototype.slice.call(article.querySelectorAll('p'));
var activePara = null; var activePara = null;
paras.forEach(function (p, i) {
p.dataset.paraIdx = i;
p.addEventListener('click', function () { seekToPara(i); });
});
function highlightPara(idx) { function highlightPara(idx) {
if (activePara) activePara.classList.remove('tts-active'); if (activePara) activePara.classList.remove('tts-active');
activePara = (idx >= 0 && idx < paras.length) ? paras[idx] : null; 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 ────────────────────────────────────────────────────────── // ── UI state helpers ──────────────────────────────────────────────────────────
function setPlayIcon(icon) {
navIcon.innerHTML = icon;
playerPlayIcon.innerHTML = icon;
}
function setGenerating() { function setGenerating() {
icon.textContent = '\u231B'; setPlayIcon('\u231B');
label.textContent = 'Generating\u2026'; navBtn.disabled = true;
setStatus('Generating audio on server\u2026'); navBtn.style.opacity = '0.6';
btn.disabled = true; playerPlayBtn.disabled = true;
btn.style.opacity = '0.6';
btn.style.cursor = 'not-allowed';
voiceSel.disabled = true; voiceSel.disabled = true;
speedSlider.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() { function setPlaying() {
icon.innerHTML = '&#9646;&#9646;'; setPlayIcon('&#9646;&#9646;');
label.textContent = 'Pause'; navBtn.disabled = false;
setStatus('Playing'); navBtn.style.opacity = '1';
btn.disabled = false; playerPlayBtn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
voiceSel.disabled = false; voiceSel.disabled = false;
speedSlider.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() { function setPaused() {
icon.innerHTML = '&#9654;'; setPlayIcon('&#9654;');
label.textContent = 'Resume'; setBadge(playerStateBadge, 'paused');
setStatus('Paused'); setStatus('');
queueSetCurrent('paused');
} }
function setStopped() { function setStopped() {
highlightPara(-1); highlightPara(-1);
icon.innerHTML = '&#9654;'; setPlayIcon('&#9654;');
label.textContent = 'Listen'; navBtn.disabled = false;
setStatus(''); navBtn.style.opacity = '1';
btn.disabled = false; playerPlayBtn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
voiceSel.disabled = false; voiceSel.disabled = false;
speedSlider.disabled = false; speedSlider.disabled = false;
queueSetCurrent('idle', 'stopped'); setBadge(playerStateBadge, 'idle');
queueSetNext('idle'); playerTitle.textContent = '—';
queueUpdateScrubber(); setStatus('');
updateScrubber();
hidePlayer();
} }
function setError(msg) { function setError(msg) {
highlightPara(-1); highlightPara(-1);
icon.innerHTML = '&#9654;'; setPlayIcon('&#9654;');
label.textContent = 'Listen'; navBtn.disabled = false;
setStatus('Error: ' + msg); navBtn.style.opacity = '1';
btn.disabled = false; playerPlayBtn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
voiceSel.disabled = false; voiceSel.disabled = false;
speedSlider.disabled = false; speedSlider.disabled = false;
queueSetCurrent('error', 'err: ' + msg); setBadge(playerStateBadge, 'error');
playerTitle.textContent = 'Error';
setStatus('Error: ' + msg);
showPlayer();
} }
// ── server-side audio generation ───────────────────────────────────────────── // ── server-side audio generation ─────────────────────────────────────────────
// POST /ui/audio/{slug}/{n} — returns {url, parts, merged}. var currentAudioCtrl = null;
// 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; }
}
function generateAudio(chapterN, cb) { function generateAudio(chapterN, cb) {
// Cancel any previous in-flight generation and polling.
if (currentAudioCtrl) { currentAudioCtrl.abort(); } if (currentAudioCtrl) { currentAudioCtrl.abort(); }
clearMergePoller();
var ctrl = new AbortController(); var ctrl = new AbortController();
currentAudioCtrl = ctrl; currentAudioCtrl = ctrl;
@@ -2412,75 +2426,42 @@ const chapterTmpl = `
.then(function (data) { .then(function (data) {
if (!data || !data.url) throw new Error('no url in response'); if (!data || !data.url) throw new Error('no url in response');
cb(data.url); 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) { .catch(function (e) {
if (e.name === 'AbortError') return; // silently cancelled if (e.name === 'AbortError') return;
setError(e.message); setError(e.message);
}); });
} }
// ── next-chapter prefetch ───────────────────────────────────────────────────── // ── next-chapter prefetch at 80% ─────────────────────────────────────────────
// At 80 % playback we silently POST to generate the next chapter's audio so var prefetchFired = false;
// it is cached by the time the current chapter ends.
var prefetchFired = false; // ensure we only fire once per chapter
audio.addEventListener('timeupdate', function () { audio.addEventListener('timeupdate', function () {
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) return; if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) return;
if (audio.currentTime / audio.duration >= 0.8) { if (audio.currentTime / audio.duration >= 0.8) {
prefetchFired = true; prefetchFired = true;
queueSetNext('generating', 'POST /ui/audio/' + SLUG + '/' + NEXT_N + ' (prefetch)'); setNextState('generating');
// Fire-and-forget: idempotent on server.
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, { fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) })
voice: voiceSel.value,
speed: parseFloat(speedSlider.value)
})
}) })
.then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); }) .then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); })
.then(function () { queueSetNext('ready', 'prefetch complete: ch.' + NEXT_N + ' cached'); }) .then(function () { setNextState('ready'); })
.catch(function (e) { queueSetNext('error', 'prefetch failed: ' + e); }); .catch(function () { setNextState('error'); });
} }
}); });
// ── stop / cleanup ──────────────────────────────────────────────────────────── // ── stop / cleanup ────────────────────────────────────────────────────────────
function stop() { function stop() {
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; } if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
clearMergePoller();
audio.pause(); audio.pause();
audio.src = ''; audio.src = '';
prefetchFired = false; prefetchFired = false;
setStopped(); setStopped();
} }
// Exposed for the Stop button in the expanded panel.
window.playerStop = stop;
// ── audio events ────────────────────────────────────────────────────────────── // ── audio events ──────────────────────────────────────────────────────────────
audio.addEventListener('canplay', function () { audio.addEventListener('canplay', function () {
@@ -2494,9 +2475,8 @@ const chapterTmpl = `
audio.addEventListener('play', setPlaying); audio.addEventListener('play', setPlaying);
audio.addEventListener('error', function () { setError('audio error'); }); audio.addEventListener('error', function () { setError('audio error'); });
// Sync paragraph highlight and scrubber while playing (via timeupdate).
audio.addEventListener('timeupdate', function () { audio.addEventListener('timeupdate', function () {
queueUpdateScrubber(); updateScrubber();
if (!audio.duration || !isFinite(audio.duration) || paras.length === 0) return; if (!audio.duration || !isFinite(audio.duration) || paras.length === 0) return;
var idx = Math.min( var idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * paras.length), Math.floor((audio.currentTime / audio.duration) * paras.length),
@@ -2533,12 +2513,10 @@ const chapterTmpl = `
generateAudio(CHAPTER_N, function (url) { generateAudio(CHAPTER_N, function (url) {
audio.src = url; audio.src = url;
audio.load(); audio.load();
// canplay event triggers play(); setPlaying() re-enables controls.
}); });
} }
window.ttsToggle = function () { window.ttsToggle = function () {
// Already have audio loaded — just play/pause.
if (audio.src) { if (audio.src) {
if (audio.paused) { if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); }); audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
@@ -2559,51 +2537,34 @@ const chapterTmpl = `
setTimeout(startAudio, 100); setTimeout(startAudio, 100);
} }
// ── initialise queue bar on page load ──────────────────────────────────────── // ── init next-chapter display ────────────────────────────────────────────────
queueSetCurrent('idle', 'ready — ch.' + CHAPTER_N); if (NEXT_N) { setNextState('idle'); }
if (NEXT_N) { queueSetNext('idle', 'waiting — ch.' + NEXT_N); }
// ── double-tap left/right to navigate chapters ─────────────────────────────── // ── 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() { (function initDoubleTap() {
var lastTap = 0; var lastTap = 0;
var lastSide = ''; // 'left' | 'right' var lastSide = '';
var THRESHOLD = 300; // ms between taps var THRESHOLD = 300;
var INTERACTIVE = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL']; var INTERACTIVE = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'];
function navigate(url) { function navigate(url) { window.location.href = url; }
window.location.href = url;
}
document.addEventListener('touchend', function (e) { document.addEventListener('touchend', function (e) {
// Ignore taps that land on interactive elements
var el = e.target; var el = e.target;
while (el && el !== document.body) { while (el && el !== document.body) {
if (INTERACTIVE.indexOf(el.tagName) !== -1) return; if (INTERACTIVE.indexOf(el.tagName) !== -1) return;
el = el.parentElement; el = el.parentElement;
} }
var touch = e.changedTouches[0];
var touch = e.changedTouches[0]; var side = touch.clientX < window.innerWidth / 2 ? 'left' : 'right';
var side = touch.clientX < window.innerWidth / 2 ? 'left' : 'right'; var now = Date.now();
var now = Date.now(); var gap = now - lastTap;
var gap = now - lastTap;
if (gap < THRESHOLD && side === lastSide) { if (gap < THRESHOLD && side === lastSide) {
// Double-tap detected lastTap = 0; lastSide = '';
lastTap = 0; if (side === 'right' && NEXT_N) navigate('/books/' + SLUG + '/chapters/' + NEXT_N);
lastSide = ''; else if (side === 'left' && PREV_N) navigate('/books/' + SLUG + '/chapters/' + PREV_N);
if (side === 'right' && NEXT_N) {
navigate('/books/' + SLUG + '/chapters/' + NEXT_N);
} else if (side === 'left' && PREV_N) {
navigate('/books/' + SLUG + '/chapters/' + PREV_N);
}
} else { } else {
lastTap = now; lastTap = now; lastSide = side;
lastSide = side;
} }
}, { passive: true }); }, { passive: true });
}()); }());