Fix summary overlay scrolling and paragraph formatting on mobile
- 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:
@@ -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) {
|
||||||
|
|||||||
@@ -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">▶</span>
|
<span id="tts-icon" aria-hidden="true">▶</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]">▲</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">▶</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">▲</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">
|
||||||
|
■ 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 ? '▲' : '▼';
|
||||||
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 ? '▲' : '▼';
|
|
||||||
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 = '▮▮';
|
setPlayIcon('▮▮');
|
||||||
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 = '▶';
|
setPlayIcon('▶');
|
||||||
label.textContent = 'Resume';
|
setBadge(playerStateBadge, 'paused');
|
||||||
setStatus('Paused');
|
setStatus('');
|
||||||
queueSetCurrent('paused');
|
|
||||||
}
|
}
|
||||||
function setStopped() {
|
function setStopped() {
|
||||||
highlightPara(-1);
|
highlightPara(-1);
|
||||||
icon.innerHTML = '▶';
|
setPlayIcon('▶');
|
||||||
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 = '▶';
|
setPlayIcon('▶');
|
||||||
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 });
|
||||||
}());
|
}());
|
||||||
|
|||||||
Reference in New Issue
Block a user