Add chunked TTS audio generation and ranking search autocomplete on home page
- Split chapter TTS into up to 10 paragraph-aligned parts; part 0 is
generated synchronously so playback starts immediately, parts 1-9 and
the final merge happen in a background goroutine
- New routes: GET /ui/audio/{slug}/{n}/status (merge poll) and
GET /ui/audio-file/{slug}/{n}/part/{p} (serve individual part)
- JS polls status every 3 s and seamlessly swaps audio.src to the merged
file once ready, preserving playback position proportionally
- Home page scrape form replaced with a ranking-search autocomplete:
type a title/author to see matching ranking items (cover + metadata),
click or keyboard-select to inject the source URL, or paste a raw URL
directly; ranking data is embedded as JSON at page render time
This commit is contained in:
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
@@ -137,15 +138,25 @@ const homeTmpl = `
|
||||
<!-- Scrape form -->
|
||||
<div class="mb-10 rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<h2 class="text-sm font-semibold text-zinc-300 mb-3">Scrape a new book</h2>
|
||||
<form hx-post="/ui/scrape/book"
|
||||
<form id="scrape-form"
|
||||
hx-post="/ui/scrape/book"
|
||||
hx-target="#scrape-status"
|
||||
hx-swap="innerHTML"
|
||||
class="flex gap-2">
|
||||
<input type="url"
|
||||
name="url"
|
||||
required
|
||||
placeholder="https://novelfire.net/book/some-book"
|
||||
class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
||||
<div class="flex-1 relative" id="scrape-search-wrap">
|
||||
<input type="text"
|
||||
id="scrape-search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Search rankings or paste a URL…"
|
||||
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
||||
<!-- hidden url field submitted to HTMX -->
|
||||
<input type="url" name="url" id="scrape-url" required class="hidden" />
|
||||
<!-- dropdown -->
|
||||
<ul id="scrape-dropdown"
|
||||
class="hidden absolute z-50 left-0 right-0 top-full mt-1 rounded-xl border border-zinc-700 bg-zinc-900 shadow-xl max-h-80 overflow-y-auto">
|
||||
</ul>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-white text-sm font-medium transition-colors whitespace-nowrap">
|
||||
Scrape
|
||||
@@ -280,6 +291,183 @@ const homeTmpl = `
|
||||
}
|
||||
|
||||
if (input) input.addEventListener('input', filterCards);
|
||||
|
||||
/* ── ranking search / scrape autocomplete ──────────────────────────────── */
|
||||
(function () {
|
||||
var RANKING = {{.RankingJSON}};
|
||||
if (!RANKING || !RANKING.length) return;
|
||||
|
||||
var searchInput = document.getElementById('scrape-search');
|
||||
var urlInput = document.getElementById('scrape-url');
|
||||
var dropdown = document.getElementById('scrape-dropdown');
|
||||
var form = document.getElementById('scrape-form');
|
||||
if (!searchInput || !urlInput || !dropdown || !form) return;
|
||||
|
||||
var activeIdx = -1;
|
||||
|
||||
// Sync the hidden url field whenever the visible input looks like a URL.
|
||||
function syncURLField(val) {
|
||||
val = val.trim();
|
||||
if (/^https?:\/\//i.test(val)) {
|
||||
urlInput.value = val;
|
||||
} else {
|
||||
urlInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrop() {
|
||||
dropdown.classList.add('hidden');
|
||||
dropdown.innerHTML = '';
|
||||
activeIdx = -1;
|
||||
}
|
||||
|
||||
function buildItem(item, q) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-zinc-800 transition-colors';
|
||||
li.dataset.url = item.source_url || '';
|
||||
|
||||
// Cover image
|
||||
if (item.cover) {
|
||||
var img = document.createElement('img');
|
||||
img.src = item.cover;
|
||||
img.alt = '';
|
||||
img.className = 'w-10 h-14 object-cover rounded flex-shrink-0 bg-zinc-800';
|
||||
li.appendChild(img);
|
||||
} else {
|
||||
var ph = document.createElement('div');
|
||||
ph.className = 'w-10 h-14 rounded flex-shrink-0 bg-zinc-800';
|
||||
li.appendChild(ph);
|
||||
}
|
||||
|
||||
// Text block
|
||||
var txt = document.createElement('div');
|
||||
txt.className = 'min-w-0 flex-1';
|
||||
|
||||
var title = document.createElement('p');
|
||||
title.className = 'text-sm font-medium text-zinc-100 truncate';
|
||||
title.textContent = item.title || '';
|
||||
txt.appendChild(title);
|
||||
|
||||
if (item.author) {
|
||||
var author = document.createElement('p');
|
||||
author.className = 'text-xs text-zinc-400 truncate mt-0.5';
|
||||
author.textContent = item.author;
|
||||
txt.appendChild(author);
|
||||
}
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'flex gap-1.5 mt-1 flex-wrap';
|
||||
if (item.status) {
|
||||
var s = document.createElement('span');
|
||||
s.className = 'text-xs px-1.5 py-0.5 rounded-full bg-zinc-700 text-zinc-300';
|
||||
s.textContent = item.status;
|
||||
meta.appendChild(s);
|
||||
}
|
||||
if (item.rank) {
|
||||
var r = document.createElement('span');
|
||||
r.className = 'text-xs px-1.5 py-0.5 rounded-full bg-amber-900 text-amber-300';
|
||||
r.textContent = '#' + item.rank;
|
||||
meta.appendChild(r);
|
||||
}
|
||||
txt.appendChild(meta);
|
||||
li.appendChild(txt);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function setActive(idx, items) {
|
||||
var lis = dropdown.querySelectorAll('li');
|
||||
lis.forEach(function (li, i) {
|
||||
if (i === idx) li.classList.add('bg-zinc-800');
|
||||
else li.classList.remove('bg-zinc-800');
|
||||
});
|
||||
activeIdx = idx;
|
||||
if (idx >= 0 && idx < items.length) {
|
||||
searchInput.value = items[idx].title || '';
|
||||
urlInput.value = items[idx].source_url || '';
|
||||
}
|
||||
}
|
||||
|
||||
function showDrop(items, q) {
|
||||
dropdown.innerHTML = '';
|
||||
activeIdx = -1;
|
||||
if (!items.length) { closeDrop(); return; }
|
||||
items.forEach(function (item, i) {
|
||||
var li = buildItem(item, q);
|
||||
li.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault(); // keep focus on input
|
||||
searchInput.value = item.title || item.source_url || '';
|
||||
urlInput.value = item.source_url || '';
|
||||
closeDrop();
|
||||
});
|
||||
dropdown.appendChild(li);
|
||||
});
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', function () {
|
||||
var q = searchInput.value.trim().toLowerCase();
|
||||
syncURLField(searchInput.value);
|
||||
if (!q) { closeDrop(); return; }
|
||||
// If it looks like a URL, no autocomplete needed.
|
||||
if (/^https?:\/\//i.test(q)) { closeDrop(); return; }
|
||||
var results = RANKING.filter(function (item) {
|
||||
var haystack = ((item.title || '') + ' ' + (item.author || '') + ' ' + (item.status || '')).toLowerCase();
|
||||
return haystack.indexOf(q) !== -1;
|
||||
}).slice(0, 8);
|
||||
showDrop(results, q);
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keydown', function (e) {
|
||||
var lis = dropdown.querySelectorAll('li');
|
||||
var items = RANKING.filter(function (item) {
|
||||
var q = searchInput.value.trim().toLowerCase();
|
||||
var haystack = ((item.title || '') + ' ' + (item.author || '') + ' ' + (item.status || '')).toLowerCase();
|
||||
return haystack.indexOf(q) !== -1;
|
||||
}).slice(0, 8);
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive(Math.min(activeIdx + 1, lis.length - 1), items);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive(Math.max(activeIdx - 1, 0), items);
|
||||
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
||||
e.preventDefault();
|
||||
if (items[activeIdx]) {
|
||||
searchInput.value = items[activeIdx].title || items[activeIdx].source_url || '';
|
||||
urlInput.value = items[activeIdx].source_url || '';
|
||||
}
|
||||
closeDrop();
|
||||
} else if (e.key === 'Escape') {
|
||||
closeDrop();
|
||||
}
|
||||
});
|
||||
|
||||
// Validate before submit: if url field empty, treat visible input as raw URL.
|
||||
form.addEventListener('htmx:configRequest', function (e) {
|
||||
var val = searchInput.value.trim();
|
||||
if (!urlInput.value && /^https?:\/\//i.test(val)) {
|
||||
urlInput.value = val;
|
||||
}
|
||||
});
|
||||
|
||||
// Also handle plain form submit (non-HTMX fallback).
|
||||
form.addEventListener('submit', function () {
|
||||
var val = searchInput.value.trim();
|
||||
if (!urlInput.value && /^https?:\/\//i.test(val)) {
|
||||
urlInput.value = val;
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside.
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
|
||||
closeDrop();
|
||||
}
|
||||
});
|
||||
}());
|
||||
|
||||
}());
|
||||
</script>`
|
||||
|
||||
@@ -295,9 +483,22 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load ranking items for the scrape-search autocomplete.
|
||||
// Failures are non-fatal — the form degrades to a plain URL input.
|
||||
rankingItems, _ := s.writer.ReadRankingItems()
|
||||
|
||||
// Encode ranking items as JSON for embedding in the template.
|
||||
rankingJSON, _ := json.Marshal(rankingItems)
|
||||
|
||||
t := template.Must(template.New("home").Parse(homeTmpl))
|
||||
var buf bytes.Buffer
|
||||
_ = t.Execute(&buf, struct{ Books interface{} }{Books: books})
|
||||
_ = t.Execute(&buf, struct {
|
||||
Books interface{}
|
||||
RankingJSON template.JS
|
||||
}{
|
||||
Books: books,
|
||||
RankingJSON: template.JS(rankingJSON),
|
||||
})
|
||||
|
||||
s.respond(w, r, "Home", buf.String())
|
||||
}
|
||||
@@ -1619,15 +1820,22 @@ const chapterTmpl = `
|
||||
}
|
||||
|
||||
// ── server-side audio generation ─────────────────────────────────────────────
|
||||
// POST /ui/audio/{slug}/{n} — idempotent; returns {url} immediately if cached.
|
||||
// cb(url) is called with the audio-file URL on success.
|
||||
// Returns the AbortController so the caller can cancel in-flight requests.
|
||||
// POST /ui/audio/{slug}/{n} — returns {url, parts, merged}.
|
||||
// When merged=false, url is part-0; polling /ui/audio/{slug}/{n}/status
|
||||
// detects when the full merged file is ready and swaps audio.src seamlessly.
|
||||
// cb(url) is called with the initial (part-0 or merged) URL on success.
|
||||
|
||||
var currentAudioCtrl = null; // AbortController for the active generateAudio fetch
|
||||
var mergePoller = null; // setInterval id for polling merged status
|
||||
|
||||
function clearMergePoller() {
|
||||
if (mergePoller !== null) { clearInterval(mergePoller); mergePoller = null; }
|
||||
}
|
||||
|
||||
function generateAudio(chapterN, cb) {
|
||||
// Cancel any previous in-flight generation.
|
||||
// Cancel any previous in-flight generation and polling.
|
||||
if (currentAudioCtrl) { currentAudioCtrl.abort(); }
|
||||
clearMergePoller();
|
||||
var ctrl = new AbortController();
|
||||
currentAudioCtrl = ctrl;
|
||||
|
||||
@@ -1645,8 +1853,35 @@ const chapterTmpl = `
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data && data.url) cb(data.url);
|
||||
else throw new Error('no url in response');
|
||||
if (!data || !data.url) throw new Error('no url in response');
|
||||
cb(data.url);
|
||||
// If the server is still generating remaining parts, start polling.
|
||||
if (!data.merged && data.parts > 1) {
|
||||
var statusURL = '/ui/audio/' + SLUG + '/' + chapterN + '/status'
|
||||
+ '?voice=' + encodeURIComponent(voiceSel.value)
|
||||
+ '&speed=' + parseFloat(speedSlider.value);
|
||||
mergePoller = setInterval(function () {
|
||||
fetch(statusURL)
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||
.then(function (s) {
|
||||
if (s.merged) {
|
||||
clearMergePoller();
|
||||
// Seamlessly swap to the full merged file.
|
||||
var ratio = (audio.duration && isFinite(audio.duration))
|
||||
? audio.currentTime / audio.duration : 0;
|
||||
audio.addEventListener('loadedmetadata', function onMeta() {
|
||||
audio.removeEventListener('loadedmetadata', onMeta);
|
||||
if (audio.duration && isFinite(audio.duration)) {
|
||||
audio.currentTime = ratio * audio.duration;
|
||||
}
|
||||
}, { once: true });
|
||||
audio.src = s.url;
|
||||
audio.load();
|
||||
}
|
||||
})
|
||||
.catch(function () { /* ignore transient poll errors */ });
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (e.name === 'AbortError') return; // silently cancelled
|
||||
@@ -1683,6 +1918,7 @@ const chapterTmpl = `
|
||||
// ── stop / cleanup ────────────────────────────────────────────────────────────
|
||||
function stop() {
|
||||
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
|
||||
clearMergePoller();
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
prefetchFired = false;
|
||||
|
||||
Reference in New Issue
Block a user