Compare commits

...

6 Commits
v1.0.2 ... main

Author SHA1 Message Date
Admin
8a5bee7c09 ci: pin staticcheck as go tool, use go tool runner
Some checks failed
CI / Lint (push) Failing after 1m9s
CI / Test (push) Successful in 42s
CI / Build (push) Has been skipped
2026-03-06 11:01:09 +05:00
Admin
09e90d2ab0 feat: play cached TTS audio immediately on auto-start if already generated
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-03-03 13:46:37 +05:00
Admin
f28de2195d fix: always autoplay after generation and resume TTS on HTMX chapter swap
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-03-02 19:43:48 +05:00
Admin
9388a355e5 fix: show paused state instead of error when autoplay policy blocks play()
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
When the browser blocks audio.play() with NotAllowedError (no user gesture),
the player was surfacing an error badge and the user had no hint it was ready.
Now onCanPlay catches NotAllowedError specifically and calls setPaused() so
the audio element keeps its src and the user just needs to tap play once.
2026-03-02 19:18:28 +05:00
Admin
1c547519e8 fix: speed controls local playbackRate only, TTS generation always uses speed 1
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Speed buttons now set audio.playbackRate directly (on load and on change)
instead of passing the speed to Kokoro. The TTS generation request always
sends speed:1 so the cached audio is at normal pitch/tempo and the browser
handles tempo adjustment locally.
2026-03-02 18:50:46 +05:00
Admin
1536c7dad1 fix: use audio.autoplay flag to unblock autoplay policy on chapter auto-advance
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
When the auto-next feature navigated to a new chapter and startAudio was
called on page load (outside a user gesture), the browser's autoplay policy
blocked the audio.play() call inside onCanPlay. Fix by setting
audio.autoplay = true before audio.load() when started automatically, then
resetting it in onCanPlay so manual pause/play is unaffected.
2026-03-02 15:13:24 +05:00
5 changed files with 106 additions and 14 deletions

View File

@@ -33,9 +33,7 @@ jobs:
run: go vet ./...
- name: staticcheck
run: |
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
run: go tool staticcheck ./...
# ── tests ────────────────────────────────────────────────────────────────────
test:

View File

@@ -3,8 +3,16 @@ module github.com/libnovel/scraper
go 1.25.0
require (
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.7.0 // indirect
)
tool honnef.co/go/tools/cmd/staticcheck

View File

@@ -1,9 +1,22 @@
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=

View File

@@ -134,6 +134,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
`{"error":"audio generation timed out"}`,
)
mux.Handle("POST /ui/audio/{slug}/{n}", audioGenHandler)
// Status route: returns the proxy URL if audio was already generated, 404 otherwise.
mux.HandleFunc("GET /ui/audio/{slug}/{n}", s.handleAudioStatus)
// Proxy route: fetches the generated file from Kokoro /v1/download/{filename}.
mux.HandleFunc("GET /ui/audio-proxy/{slug}/{n}", s.handleAudioProxy)
@@ -355,6 +357,39 @@ func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, v
})
}
// handleAudioStatus handles GET /ui/audio/{slug}/{n}.
// Returns the proxy URL if audio was already generated this session, 404 otherwise.
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
}
}
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, `{"error":"not generated"}`, http.StatusNotFound)
return
}
s.writeAudioResponse(w, slug, n, voice, speed, 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.

View File

@@ -2704,6 +2704,7 @@ const chapterTmpl = `
b.classList.toggle('text-zinc-300', !active);
});
try { localStorage.setItem(LS_SPEED, btn.dataset.speed); } catch(_) {}
if (audio.src) audio.playbackRate = parseFloat(btn.dataset.speed);
}
window.selectSpeed = selectSpeed;
(function loadSettings() {
@@ -2841,7 +2842,7 @@ const chapterTmpl = `
fetch('/ui/audio/' + SLUG + '/' + chapterN, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: voiceSel.value, speed: getSpeed() }),
body: JSON.stringify({ voice: voiceSel.value, speed: 1 }),
signal: ctrl.signal
})
.then(function (res) {
@@ -2871,7 +2872,7 @@ const chapterTmpl = `
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
}
function onCanPlay() { if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; } if (audio.paused) audio.play().then(setPlaying).catch(function(e){ setError(e.message); }); }
function onCanPlay() { if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; } audio.autoplay = false; if (audio.paused) audio.play().then(setPlaying).catch(function(e){ if (e.name === 'NotAllowedError') setPaused(); else setError(e.message); }); }
function onWaiting() { if (!stale()) setStatus('Buffering\u2026'); }
function onPlaying() { if (!stale()) setPlaying(); }
function onPause() { if (!stale() && !audio.ended) setPaused(); }
@@ -2921,14 +2922,43 @@ const chapterTmpl = `
navigator.mediaSession.setActionHandler('seekto', function(d) { if (isFinite(d.seekTime)) { audio.currentTime = d.seekTime; updateSeek(); } });
}
function startAudio() {
function loadAndPlay(url) {
// Always set autoplay=true before load(): the browser needs this hint set
// synchronously before load() regardless of whether we got here via a user
// gesture or a programmatic trigger (e.g. chapter transition). The canplay
// handler will call play() as well, but autoplay on the element is the only
// reliable path after an async fetch where the gesture stack is gone.
audio.autoplay = true;
audio.src = url; audio.load();
audio.playbackRate = getSpeed();
registerMediaSession();
}
function startAudio(autoplay) {
setGenerating();
highlightPara(0);
generateAudio(CHAPTER_N, function (url) {
if (stale()) return;
audio.src = url; audio.load();
registerMediaSession();
});
// Check if audio was already generated for this chapter this session.
// If so, play it directly without re-generating.
fetch('/ui/audio/' + SLUG + '/' + CHAPTER_N + '?voice=' + encodeURIComponent(voiceSel.value) + '&speed=1')
.then(function(res) {
if (stale()) return;
if (res.ok) {
return res.json().then(function(data) {
if (stale()) return;
if (data && data.url) { loadAndPlay(data.url); return; }
throw new Error('no url in response');
});
}
// Not cached yet — fall through to generation.
generateAudio(CHAPTER_N, function(url) {
if (stale()) return;
loadAndPlay(url);
});
})
.catch(function(e) {
if (stale()) return;
setError(e.message);
});
}
window.ttsToggle = function () {
@@ -2940,7 +2970,15 @@ const chapterTmpl = `
startAudio();
};
window.__ttsBeforeSwap = function () { window.__ttsGen++; stop(); };
window.__ttsBeforeSwap = function () {
// If TTS is active (generating or has audio loaded), persist the autostart
// flag so the incoming chapter's init code will restart TTS automatically.
var wasActive = currentAudioCtrl !== null || (audio.src && !audio.ended);
if (wasActive) {
try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
}
window.__ttsGen++; stop();
};
document.body.addEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
var _autostart = false;
@@ -2948,10 +2986,10 @@ const chapterTmpl = `
if (_autostart) {
try { localStorage.removeItem('tts_autostart'); } catch(_) {}
autoplayChk.checked = true;
setTimeout(startAudio, 100);
setTimeout(function() { startAudio(true); }, 100);
} else if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
autoplayChk.checked = true;
setTimeout(startAudio, 100);
setTimeout(function() { startAudio(true); }, 100);
}
// ── double-tap left/right to navigate ────────────────────────────────────