feat: HTTP TTS 与双端兼容朗读,书架体验与自定义设置

朗读(Closes #13 的朗读部分)

新增服务端 HTTP TTS 代理与客户端第二、第三个朗读引擎,三者在同一个
SpeechEngine 接口下可切换:

- 服务端 GET /api/v1/tts 代理 TTS_URL 指向的合成服务(edge-tts / Piper
  等兼容形态),只代理不内置合成模型;GET /api/v1/tts/voices 回答
  「这个实例能用什么」,未配置时返回 http:false 而不是 404。
  按句合成、磁盘缓存、上游返回非 audio/* 时明确报错(一个指到网页的
  TTS_URL 会以 200 返回 HTML,塞进 audio 里既没声音也没事件)。
  /tts 是精确的查询串令牌白名单项之一:<audio src> 无法带 Authorization。
- 客户端 HttpTtsEngine 用 <audio> 而不是 Web Audio,事件驱动推进队列。
- 客户端 NativeTtsEngine 走 Android 外壳新增的 speech 接口(shell v3):
  WebView 自带的合成器与系统的是两个引擎,手机系统装好的中文语音经常
  不在 getVoices() 里,这是「朗读没有中文声音」最常见的原因。
- speechAvailability 决定 auto 用哪个引擎:native → system → http。
- 原 TtsEngine 抽出 loadQueue/onChunk/onState 为公开字段以结构化满足接口。

书架

- 排序:最近更新 / 最近入库 / 书名 / 作者,中文按数字大小排。
  排序规则抽到 shelf-order.ts,离线路径与服务端用同一条规则。
- 密度、作者显隐、封面进度条;搜索一键清空 + 回车即搜;下拉刷新。
- 服务端 BookDto 增加 addedAt,否则客户端离线排序无法复现服务端结果。

自定义设置

- 朗读引擎可选(引擎多于一个时才显示该行);音调行只对有声调的引擎出现。
- 书架设置独立成 sheet,与阅读面板分开。

测试与文档

- server: tts.test.ts(10 例)。web: tts-http / speech / shelf-order
  新增 39 例,合计 245 passing。
- android/scripts/check-shell.sh 修正为同时校验嵌套的 speech 接口,
  之前它只能看到一个顶层接口。
- README / docs/api.md / docs/architecture.md 补齐三个引擎的取舍、
  环境变量与错误码。

Ref: #13
This commit is contained in:
cnb.dQC5J6ohAGA committed 2026-09-16 23:29:41 +08:00
1 parent 3910c95661
commit 0370cd98e8
36 files changed
+4394 -101

No files matched your search

+47 -4
View File
@@ -116,20 +116,56 @@ volumes:
### 朗读(TTS)
用系统自带的语音合成,不需要联网、不额外占容器的体积,也不把你的书上传到任何服务。
三种引擎,一套控制。用哪一个是 `auto` 决定的,也可以手动指定:
| 引擎 | 声音来自 | 什么时候用 |
| --- | --- | --- |
| 系统语音(原生) | Android 外壳的 `TextToSpeech` | Android 客户端默认 |
| 系统语音(浏览器) | 浏览器 / WebView 的 `speechSynthesis` | Web 端默认 |
| HTTP 朗读 | 服务端 `TTS_URL` 指向的合成服务 | 前两者不可用时 |
**为什么 Android 要用原生引擎**:WebView 自带的合成器和系统的是**两个引擎**,
手机上系统装好的中文语音经常不在 WebView 的语音列表里——于是读者听到英文或听不到声音,
而 Web 层拿到的列表本身就是缺的,无从修起。这是「朗读没有中文声音」最常见的原因。
**HTTP 朗读**:给没有 `speechSynthesis` 的浏览器(比如 Linux 上的 Firefox)、
或者没有中文语音的设备用。服务端只做代理,不内置合成模型——容器体积和 NAS 的 CPU 预算
都不该为它买单。在 `docker-compose.yml` 里加:
```yaml
environment:
TTS_URL: "http://host.docker.internal:5002/tts" # edge-tts / Piper 等兼容服务
# TTS_TOKEN: "..." # 需要鉴权的上游
# TTS_VOICES_URL: "..." # 语音列表;默认 <TTS_URL>/voices
# TTS_CACHE_BYTES: "268435456" # 音频磁盘缓存上限,0 关闭
```
**共同行为**:
- **按句切分**:中文按 `。!?;…`,西文按 `. ! ?` 且避开 `Mr.` / `3.14` / `e.g.`。
逐句朗读才能做到朗读位置跟随显示高亮,也能让「暂停/继续」和「改语速」都只影响下一句。
- **从当前位置开始**:默认从句首读起;若先选中一段文字再按朗读,就从选中处开始。
- **句子级控制**:上一句 / 下一句 / 进度条直接跳句。
- **语速、音调、音量**:语速 0.5×–2.5×,改动即时生效(不重读整段,只重读当前句)。
- **语音可选**:列出系统里所有可用语音(含语言标签),默认「跟随系统」。
音调只对系统/原生引擎显示——合成音频没有不改变语速的音调控制,所以那一行直接不出现。
- **语音可选**:列出当前引擎所有可用语音(含语言标签),默认「跟随系统」。
- **章节播完**:可选「继续下一章」或「停在章末」——后者就是有声书的「一次一章」。
- **高亮**:正在朗读的句子用半透明底色标出,并用 `Range` 绘制,**不改写书的 DOM**,
所以开始朗读不会让章节重新分页。
> 浏览器端朗读依赖 `speechSynthesis`(Chrome / Edge / Android WebView 都有)。
> 没有该 API 的环境里,设置面板会直接说明,而不是给一个按了没反应的按钮。
> 一个引擎都没有时,设置面板会直接说明原因和解决办法,而不是给一个按了没反应的按钮。
### 书架
- **排序**:最近更新 / 最近入库 / 书名 / 作者,书架上一键切换,并记住选择。
中文书名按数字大小排(`第9章` 在 `第10章` 前),不是按字符码。
- **密度**:紧凑 / 适中 / 宽松,封面一行放几个。手机上从一行三个到一行两个。
- **显示**:可关掉作者、可关掉封面上的进度条。书架安静下来之后更像书库,不像仪表盘。
- **封面进度条**:读过的书在封面底部画一条,扫一眼就知道哪本在读。
- **搜索**:输入即搜(防抖 250ms),带一键清空;回车立刻搜不等防抖。
- **下拉刷新**:在顶部再往下拉一次,手动重新拉取(刚扫完目录时用)。
- **离线优先**:先画本地镜像再换成服务端数据,离线时书架照常可用;
离线下的排序规则与服务端一致,所以断网不会让书架重排。
---
@@ -270,6 +306,13 @@ EPUB 永远留在 WebView:文字排版是差异点,只能有一份实现。
| `REFRESH_TOKEN_TTL` | `31536000` | 刷新令牌有效期(秒) |
| `READER_TOKEN_SECRET` | 自动生成 | 签名密钥。留空则生成并持久化到 `/data` |
| `LOG_LEVEL` | `info` | 日志级别 |
| `CORS_ORIGINS` | 空 | 允许跨域调用的来源,逗号分隔。空表示反射任意来源 |
| `WEB_DIR` | `./web` | H5 客户端产物目录,镜像里已设好 |
| `TTS_URL` | 空 | HTTP 朗读上游地址。**不设则关闭 HTTP 朗读** |
| `TTS_TOKEN` | 空 | 转发给上游的 Bearer 令牌 |
| `TTS_VOICES_URL` | `<TTS_URL>/voices` | 上游语音列表地址 |
| `TTS_TIMEOUT_MS` | `20000` | 单次合成超时(毫秒) |
| `TTS_CACHE_BYTES` | `268435456` | 音频磁盘缓存上限,存放于 `DATA_DIR/tts-cache`;0 关闭 |
> `READER_TOKEN_SECRET` 留空时会在首次启动生成并存到 `DATA_DIR/token.secret`。
> 只要 `/data` 还在,重启不会踢掉所有客户端。**删掉 `/data` 会清空所有账号和进度。**
@@ -13,6 +13,7 @@ import androidx.core.view.WindowInsetsCompat
import android.widget.FrameLayout
import cool.cnb.reader.bridge.ConnectivityMonitor
import cool.cnb.reader.bridge.ReaderBridge
import cool.cnb.reader.bridge.SpeechBridge
import cool.cnb.reader.web.NativePageView
import cool.cnb.reader.web.WebHost
@@ -30,6 +31,13 @@ class MainActivity : AppCompatActivity() {
private lateinit var connectivity: ConnectivityMonitor
private var host: WebHost? = null
private var pageView: NativePageView? = null
/**
* The platform speech engine, held so it can be shut down.
*
* `TextToSpeech` binds a service and holds the audio route; releasing it in
* `onDestroy` is what stops a backgrounded app from continuing to speak.
*/
private var speechBridge: SpeechBridge? = null
/**
* Current image-fit preference, pushed down by the web layer.
@@ -97,6 +105,11 @@ class MainActivity : AppCompatActivity() {
),
)
}
// The speech engine is a bound service and a speaker; it is released when
// the activity goes away rather than left to the process's own cleanup,
// which on Android can be much later — a reader who backgrounds the app
// while listening should not be read to from the past.
speechBridge = host?.speechBridge
// Back should walk the reader's own history (shelf → book → shelf) rather
// than exiting the app, and only leave when there is nothing left.
@@ -115,6 +128,19 @@ class MainActivity : AppCompatActivity() {
)
}
/**
* Releases the platform speech engine.
*
* `onDestroy` rather than `onStop`: a WebView page can survive a stop (the
* reader comes back to the same chapter), and shutting the engine down there
* would make the first sentence after returning fail. On destroy the page is
* gone, so there is nothing left for the engine to speak.
*/
override fun onDestroy() {
speechBridge?.shutdown()
super.onDestroy()
}
/**
* Keeps the WebView clear of the status bar, the navigation bar and the
* on-screen keyboard.
@@ -58,6 +58,16 @@ class ReaderBridge(
private val mainHandler = Handler(Looper.getMainLooper())
/**
* The platform speech engine, exposed as `window.ReaderAndroid.speech`.
*
* Constructed eagerly and lazily *initialised*: creating the object is free,
* whereas creating the `TextToSpeech` engine binds a service and is not — so
* the web layer decides when to pay that, by calling `init()` when the reader
* first opens the朗读 settings rather than on every app start.
*/
private val speechBridge = SpeechBridge(context, webView)
/**
* Runs a UI-thread block and waits for its result.
*
@@ -260,6 +270,22 @@ class ReaderBridge(
return false
}
/**
* The native speech surface, as a nested object.
*
* `window.ReaderAndroid.speech` rather than seven more top-level methods, for
* a reason that matters on the client side: it is a *feature*, not a
* capability, and a shell without it is a perfectly good shell — the client
* falls back to the WebView's own `speechSynthesis`, which every Android
* WebView has. One property read is the whole feature check.
*
* See `SpeechBridge` for why the platform's `TextToSpeech` is worth having
* when the WebView already exposes one: it is a different engine, and the
* WebView's `getVoices()` routinely misses the voices the OS has installed.
*/
@JavascriptInterface
fun speech(): SpeechBridge = speechBridge
private fun appVersion(): String = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
packageInfo.versionName ?: "unknown"
@@ -298,6 +324,16 @@ class ReaderBridge(
/** Name the web client looks for on `window`. */
const val NAME = "ReaderAndroid"
/**
* Name of the nested speech interface.
*
* `@JavascriptInterface` can register objects, not properties of one, so
* "`ReaderAndroid.speech`" is really a second interface that the client
* re-parents onto the first. The name is therefore part of the contract:
* `web/src/core/android-platform.ts` reads exactly this key.
*/
const val SPEECH_NAME = "ReaderAndroidSpeech"
/**
* Bump when a method is added that the client cannot work without, and
* raise MIN_SHELL_VERSION in `web/src/core/android-platform.ts` to match.
@@ -306,8 +342,15 @@ class ReaderBridge(
* shell below 2 as "no native page renderer" and draws everything in the
* WebView, so the version check is how an old APK degrades instead of
* calling a method that does not exist.
*
* 3 added `speech` (native `TextToSpeech`). Unlike version 2 this is a
* *feature* rather than a capability: a shell below 3 simply reads with
* the WebView's own synthesizer, which is what happens today. The version
* still matters, because it is how the client knows whether to offer
* "系统语音(原生)" in the settings rather than discovering at play time
* that the method does not exist.
*/
const val SHELL_VERSION = 2
const val SHELL_VERSION = 3
/**
* Callback names must be plain identifiers.
@@ -0,0 +1,405 @@
package cool.cnb.reader.bridge
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.speech.tts.Voice
import android.webkit.JavascriptInterface
import android.webkit.WebView
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
/**
* The platform's own `TextToSpeech`, exposed to the web layer as
* `window.ReaderAndroid.speech`.
*
* ## Why this exists when every Android WebView already has `speechSynthesis`
*
* Because they are not the same synthesizer, and the WebView's copy is the worse
* one on exactly the phones that need Chinese speech:
*
* - A voice installed for the *operating system* (a Chinese voice from the
* vendor's TTS pack, or one the reader installed from the Play Store) is
* routinely absent from the WebView's `getVoices()`. The reader hears English
* or hears nothing, and nothing in the web layer can fix it — the list it is
* given is simply missing the voice.
* - `getVoices()` in a WebView is also asynchronous in a way a cold start gets
* wrong: the first call returns an empty array, and the code that waits for
* `voiceschanged` is code a phone may never run if the reader taps play first.
* - `TextToSpeech` reports *why* it failed through `onError`, and that reason is
* what turns "朗读没有声音" into a message the reader can act on.
*
* ## What stays in JavaScript
*
* The queue, the cursor, the chapter boundary, the highlight. This class speaks
* one sentence and reports when it is done — the same contract the WebView engine
* has, which is what makes the two interchangeable. A native queue would be a
* second implementation of rules that already exist in one place, and the two
* would drift.
*
* ## Threading
*
* `@JavascriptInterface` calls arrive on a WebView-owned thread; `TextToSpeech`
* and `Handler` want the main looper. Every method here therefore posts to the
* main thread, and callbacks go back through `evaluateJavascript`, which is the
* only direction a WebView allows without a `WebMessagePort` handshake.
*/
class SpeechBridge(
private val context: Context,
private val webView: WebView,
) {
private val mainHandler = Handler(Looper.getMainLooper())
private var tts: TextToSpeech? = null
private var ready = false
private var rate = 1f
private var pitch = 1f
private var volume = 1f
private var voiceId = ""
private var eventCallback: String? = null
private var voicesCallback: String? = null
/**
* Id of the sentence currently being spoken.
*
* Supplied by the *client*, not minted here, and echoed back on every event.
* A late `onDone` from a sentence the reader has already moved past must not
* advance the web layer's cursor, and an id is the only way to tell the two
* apart: `UtteranceProgressListener` gives an id and nothing else. The client
* is the side that knows which sentence is on screen, so it is the side that
* names it.
*/
private var currentUtterance = ""
private val progressListener = object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
if (utteranceId == null || utteranceId != currentUtterance) return
// Emitted for parity with the WebView engine's `onstart`, and for
// diagnostics: a reader who sees "playing" but hears nothing can be
// told whether the engine ever started.
emit("start", null)
}
override fun onDone(utteranceId: String?) {
if (utteranceId == null || utteranceId != currentUtterance) return
currentUtterance = ""
emit("done", null)
}
@Deprecated("Required by the platform interface; onError(id, code) is the one that carries a reason")
override fun onError(utteranceId: String?) {
if (utteranceId != null && utteranceId != currentUtterance) return
currentUtterance = ""
emit("error", "朗读失败")
}
override fun onError(utteranceId: String?, errorCode: Int) {
if (utteranceId != null && utteranceId != currentUtterance) return
currentUtterance = ""
emit("error", errorMessage(errorCode))
}
}
/**
* Creates the engine and loads voices.
*
* Idempotent. The voice list is pushed when the `OnInitListener` fires rather
* than returned from here, because initialisation is asynchronous and a
* synchronous return would be a lie.
*/
@JavascriptInterface
fun init() {
mainHandler.post {
if (tts != null) {
deliverVoices()
return@post
}
tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
ready = true
tts?.setOnUtteranceProgressListener(progressListener)
configure()
deliverVoices()
emit("ready", null)
} else {
ready = false
emit("error", "系统语音引擎初始化失败")
}
}
}
}
/** Whether the engine finished initialising. */
@JavascriptInterface
fun available(): Boolean = ready
/**
* Registers the callback that receives `ready` / `start` / `done` / `error`.
*
* One callback with a typed event rather than three named ones: the client's
* handler is a single `when` on the type either way, and three registrations
* would be three chances for one of them to be missed.
*/
@JavascriptInterface
fun onSpeechEvent(callbackName: String) {
if (!SAFE_IDENTIFIER.matches(callbackName)) return
eventCallback = callbackName
}
/** Registers the callback that receives the voice list, JSON-encoded. */
@JavascriptInterface
fun onVoices(callbackName: String) {
if (!SAFE_IDENTIFIER.matches(callbackName)) return
voicesCallback = callbackName
mainHandler.post { deliverVoices() }
}
/**
* Speaks one sentence, replacing anything in flight.
*
* `QUEUE_FLUSH` rather than `QUEUE_ADD` is what makes the client's
* one-sentence-at-a-time queue work: the client already decided what to say
* next, so anything still queued in the engine is a leftover from a sentence
* the reader moved past.
*/
@JavascriptInterface
fun speak(text: String, utteranceId: String) {
val value = text.trim()
if (value.isEmpty()) return
mainHandler.post {
val engine = tts ?: return@post
if (!ready) return@post
// A very long utterance makes several vendor stacks go silent, exactly
// as it does in a WebView. The client splits far below this, but a
// defensive cut costs nothing and turns a hang into a short sentence.
val bounded = value.take(MAX_UTTERANCE)
// The client's id when it sent one, a minted one otherwise, so an
// older client that only sends text still gets its events matched
// against the sentence in flight rather than being ignored.
currentUtterance = utteranceId.ifBlank { "u" + System.nanoTime() }.take(ID_MAX_LENGTH)
val params = Bundle()
params.putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume)
// The id is the third argument on API 21+ and the params map on older
// releases; `speak(CharSequence, int, Bundle, String)` is the only
// overload this project targets (minSdk 24), so the deprecated forms
// are not needed.
val result = engine.speak(bounded, TextToSpeech.QUEUE_FLUSH, params, currentUtterance)
if (result == TextToSpeech.ERROR) {
currentUtterance = ""
emit("error", "朗读启动失败")
}
}
}
/**
* Stops speaking.
*
* This is also what "pause" maps to (see `pause`), because
* `TextToSpeech` has no pause.
*/
@JavascriptInterface
fun stop() {
mainHandler.post {
currentUtterance = ""
tts?.stop()
}
}
/**
* Pauses, by stopping.
*
* The web layer replays the current sentence on resume — the same behaviour
* the `<audio>` engine has, so switching engines does not change what the
* reader hears. A real pause would have to be a native queue, and that is the
* second implementation this design refuses.
*/
@JavascriptInterface
fun pause() {
stop()
}
@JavascriptInterface
fun setRate(value: Float) {
rate = value.coerceIn(0.5f, 3f)
mainHandler.post { configure() }
}
@JavascriptInterface
fun setPitch(value: Float) {
pitch = value.coerceIn(0.5f, 2f)
mainHandler.post { configure() }
}
@JavascriptInterface
fun setVolume(value: Float) {
volume = value.coerceIn(0f, 1f)
}
/**
* Selects a voice.
*
* The id is whatever `deliverVoices` handed out, which is the platform's own
* `Voice.name`. An unknown id is ignored rather than reported as an error:
* settings are per device and the voice list is per platform, so a stored id
* from another phone is a normal state, not a bug.
*/
@JavascriptInterface
fun setVoice(id: String) {
voiceId = id
mainHandler.post { configure() }
}
/** Releases the engine. Called when the reader screen goes away. */
@JavascriptInterface
fun shutdown() {
mainHandler.post {
currentUtterance = ""
tts?.stop()
tts?.shutdown()
tts = null
ready = false
}
}
// ---- internals ----
private fun configure() {
val engine = tts ?: return
engine.setSpeechRate(rate)
engine.setPitch(pitch)
if (voiceId.isNotEmpty()) {
val wanted = engine.voices?.firstOrNull { it.name == voiceId }
if (wanted != null && !wanted.isNotInstalled) {
engine.voice = wanted
return
}
}
// No explicit voice, or one this device does not have: fall back to the
// device locale, which is the same choice "跟随系统" means in the UI.
engine.setLanguage(Locale.getDefault())
}
/**
* Pushes the voice list to the client, JSON-encoded.
*
* Called on init and again whenever the client registers a callback, because
* either can happen first: the client registers then calls `init`, but a slow
* `OnInitListener` can outlive a WebView reload, and the voices gathered
* before the reload must not be lost.
*/
private fun deliverVoices() {
val callback = voicesCallback ?: return
val engine = tts ?: return
if (!ready) return
val list = JSONArray()
val voices: Collection<Voice> = try {
engine.voices ?: emptySet()
} catch (_: Exception) {
emptySet()
}
val defaultVoiceName = try {
engine.defaultVoice?.name
} catch (_: Exception) {
null
}
for (voice in voices) {
// A voice that is not installed cannot be used, and offering it would
// make the reader choose a voice that says nothing.
if (voice.isNotInstalled) continue
val entry = JSONObject()
entry.put("id", voice.name)
entry.put("name", friendlyName(voice))
entry.put("lang", voice.locale.toLanguageTag())
entry.put("default", voice.name == defaultVoiceName)
list.put(entry)
}
evaluate(callback, list.toString())
}
/**
* A voice's name, made readable.
*
* Vendor stacks name voices things like `zh-CN-x-ccc-local` or
* `cmn-cn-x-ccc-network`, which is a package id rather than something a
* person picks from a list. The language tag is kept in front because that is
* the part a reader actually chooses on.
*/
private fun friendlyName(voice: Voice): String {
val tag = voice.locale.toLanguageTag()
val short = voice.name.substringAfter("-x-", voice.name)
return "$tag · $short"
}
private fun emit(type: String, message: String?) {
val callback = eventCallback ?: return
val payload = JSONObject()
payload.put("type", type)
if (message != null) payload.put("message", message)
// Only for events about a specific utterance. `ready` is not about one, and
// an empty id would be an id that matches nothing on the client.
if (type == "start" || type == "done" || type == "error") {
payload.put("id", currentUtterance)
}
evaluate(callback, payload.toString())
}
private fun evaluate(callbackName: String, json: String) {
if (!SAFE_IDENTIFIER.matches(callbackName)) return
mainHandler.post {
// JSON-encoded so the payload cannot break out of the call site. The
// payload is itself a JSON *string*, so it is quoted once more — the
// client's callback parses it, which is what makes a Chinese voice
// name with a quote in it survive.
webView.evaluateJavascript(
"window['$callbackName'] && window['$callbackName'](${JSONObject.quote(json)})",
null,
)
}
}
/** The error codes a reader can act on, in words. */
private fun errorMessage(code: Int): String = when (code) {
TextToSpeech.ERROR_SYNTHESIS -> "语音合成失败,请检查系统 TTS 引擎"
TextToSpeech.ERROR_SERVICE -> "语音服务不可用"
TextToSpeech.ERROR_OUTPUT -> "音频输出不可用"
TextToSpeech.ERROR_NETWORK -> "网络语音需要联网"
TextToSpeech.ERROR_NETWORK_TIMEOUT -> "网络语音超时"
TextToSpeech.ERROR_NOT_INSTALLED_YET -> "语音数据未下载完成"
else -> "朗读失败($code)"
}
companion object {
/**
* Ceiling per utterance.
*
* The client splits at 300 characters; this is the belt to that braces,
* because a vendor engine that goes silent on a long utterance is a
* failure the reader cannot diagnose.
*/
private const val MAX_UTTERANCE = 600
/**
* Longest utterance id accepted.
*
* The id is passed straight to `TextToSpeech` and echoed in an
* `evaluateJavascript` string, so it is bounded like every other string
* that crosses the bridge.
*/
private const val ID_MAX_LENGTH = 64
/**
* Callback names must be plain identifiers.
*
* The name is interpolated into an `evaluateJavascript` string, so an
* unrestricted value would let the web side run arbitrary script in its
* own context. The context is the client's own, so the risk is low, but a
* bridge that will interpolate a string should validate it.
*/
private val SAFE_IDENTIFIER = Regex("^[A-Za-z_$][A-Za-z0-9_$]{0,64}$")
}
}
@@ -15,6 +15,7 @@ import android.webkit.WebViewClient
import androidx.webkit.WebViewAssetLoader
import cool.cnb.reader.BuildConfig
import cool.cnb.reader.bridge.ReaderBridge
import cool.cnb.reader.bridge.SpeechBridge
/**
* Configures the WebView and serves the bundled client to it.
@@ -44,9 +45,35 @@ class WebHost(
.addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context))
.build()
/**
* Installs the bridge.
*
* The speech engine is registered as a *second* interface rather than reached
* through the first one, and that is a platform constraint rather than a
* preference: `@JavascriptInterface` exposes methods, not objects, so
* `window.ReaderAndroid.speech` can only exist if `speech` is added with its
* own name. The client promotes it back into the object shape it expects (see
* `installAndroidBridge` in the web layer), so the contract in
* `android-bridge.d.ts` — one nested object — is what the web layer sees
* whether or not the shell is real.
*/
/**
* The platform speech engine, for the activity to release in `onDestroy`.
*
* Exposed rather than reached through `ReaderBridge` because the activity
* holds the bridge anonymously — and because a bound service that is never
* released is a leak the platform reports, not a convenience.
*/
val speechBridge: SpeechBridge?
get() = currentBridge?.speech()
private var currentBridge: ReaderBridge? = null
fun install(bridge: ReaderBridge) {
configure()
currentBridge = bridge
webView.addJavascriptInterface(bridge, ReaderBridge.NAME)
webView.addJavascriptInterface(bridge.speech(), ReaderBridge.SPEECH_NAME)
webView.webViewClient = AssetClient()
webView.webChromeClient = ChromeClient()
webView.loadUrl(START_URL)
@@ -102,6 +129,10 @@ class WebHost(
webView.stopLoading()
webView.clearHistory()
webView.removeJavascriptInterface(ReaderBridge.NAME)
webView.removeJavascriptInterface(ReaderBridge.SPEECH_NAME)
// The interfaces are gone from the page, but the engine is a bound service
// and outlives the page: the activity releases it in `onDestroy`.
currentBridge = null
}
/**
+49 -16
View File
@@ -45,15 +45,59 @@ done
# 3. Every bridge method the web client calls must exist, and every bridge method
# the shell exposes must be declared. This is the cross-language seam.
#
# There are two interfaces, not one. `ReaderAndroid` is the top-level object;
# `SpeechBridge` is registered as a *separate* interface on the platform side
# (`addJavascriptInterface` can expose objects, not properties of one) and the
# web layer re-parents it onto `ReaderAndroid.speech`. So the check walks both
# declarations against both implementations — comparing the nested interface's
# methods against `ReaderBridge.kt` is what produced eleven false failures the
# first time this was run.
note "==> checking the JS bridge contract in both directions"
bridge_file="$android_dir/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt"
declared=$(sed -n 's/^ fun \([A-Za-z][A-Za-z0-9_]*\)(.*/\1/p' "$bridge_file" | sort -u)
speech_file="$android_dir/app/src/main/java/cool/cnb/reader/bridge/SpeechBridge.kt"
types_file="$repo_dir/web/src/android-bridge.d.ts"
consumed=$(sed -n 's/^ \([a-z][A-Za-z0-9_]*\)[?]\?(.*:.*;.*$/\1/p' "$types_file" | sort -u)
for method in $consumed; do
if ! printf '%s\n' "$declared" | grep -qx "$method"; then
err "web client calls ReaderAndroid.$method() but ReaderBridge.kt does not declare it"
# A method is "implemented" if either bridge class has it at method indentation.
# Both files are searched for both interfaces: the Kotlin class a method happens
# to live in is an implementation detail, not part of the contract.
implemented=$(sed -n 's/^ fun \([A-Za-z][A-Za-z0-9_]*\)(.*/\1/p' "$bridge_file" "$speech_file" | sort -u)
# The declaration side has two shapes, and the difference matters:
#
# - top-level members are `name(args): type;` inside `interface AndroidBridge`;
# - `speech` is a *property* whose type is a nested interface, and the nested
# interface's own methods are the second shape.
#
# Reading only the first shape is what let the speech methods go unchecked; reading
# only the second is what made an early version of this check fail on every
# ordinary method of the first.
declared_top=$(sed -n 's/^ \([a-z][A-Za-z0-9_]*\)[?]\{0,1\}(.*/\1/p' "$types_file" | sort -u)
# The nested interface's members, delimited by its own declaration.
nested_block=$(sed -n '/^export interface SpeechBridge {/,/^}/p' "$types_file")
declared_nested=$(printf '%s\n' "$nested_block" | sed -n 's/^ \([a-z][A-Za-z0-9_]*\)[?]\{0,1\}(.*/\1/p' | sort -u)
for method in $declared_top $declared_nested; do
if ! printf '%s\n' "$implemented" | grep -qx "$method"; then
err "android-bridge.d.ts declares $method() but no bridge class implements it"
fi
done
# The nested interface must be exposed, or the re-parenting in
# `promoteSpeechInterface` would attach nothing at runtime.
if printf '%s\n' "$declared_top" | grep -qx "speech"; then
if ! grep -q '^ fun speech()' "$bridge_file"; then
err "android-bridge.d.ts declares ReaderAndroid.speech but ReaderBridge.kt does not expose it"
fi
fi
# Every implemented method must also be declared, or the next person writing
# client code will not know it is available. `speech()` is the exception: it is
# declared as the nested interface's name, not as a method returning one.
for method in $implemented; do
[ "$method" = "speech" ] && continue
if ! printf '%s\n%s\n' "$declared_top" "$declared_nested" | grep -qx "$method"; then
err "a bridge class implements $method() but android-bridge.d.ts does not declare it"
fi
done
@@ -87,17 +131,6 @@ for mediaType in "image/jpeg" "image/png" "image/webp"; do
fi
done
# Every bridge method must also appear in the client-facing declaration, or the
# next person writing client code will not know it is available.
missing_from_types=0
for method in $declared; do
if ! printf '%s\n' "$consumed" | grep -qx "$method"; then
err "ReaderBridge.$method exists but is missing from android-bridge.d.ts"
missing_from_types=1
fi
done
[ "$missing_from_types" -eq 0 ] || true
if [ "$fail" -ne 0 ]; then
note "==> shell checks failed"
exit 1
+11
View File
@@ -37,6 +37,17 @@ services:
# Directory holding the built H5 client, served at `/`. Already set in the
# image; only override if you build the client separately.
# WEB_DIR: "/app/web"
# --- HTTP speech, for clients that have no engine of their own ---
# The server only *proxies*; it does not bundle a synthesizer, because
# that would multiply the image size and a NAS's CPU budget. Point this at
# any service that accepts the classic `?text=...&speed=...` shape
# (edge-tts, a local Piper wrapper, a commercial API with a shim).
#
# TTS_URL: "http://host.docker.internal:5002/tts"
# TTS_TOKEN: "" # bearer token, if the upstream wants one
# TTS_VOICES_URL: "" # defaults to <TTS_URL>/voices
# TTS_CACHE_BYTES: "268435456" # audio cache cap in /data; 0 disables
volumes:
# --- your books, mounted READ-ONLY ---
# The server NEVER writes to this directory. Replace the left-hand side
+47
View File
@@ -308,6 +308,50 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
---
## 朗读(HTTP TTS)
这两条路由只在服务端配置了 `TTS_URL` 时才有意义,并且分工明确:
一条回答「这里能用什么」,一条真正合成音频。
### `GET /tts/voices`
能力查询,朗读设置面板打开时调用一次。
→ `{ "http": false, "formats": ["audio/mpeg", ...], "maxLength": 800, "voices": [] }`
- 未配置 `TTS_URL` 时返回 `http: false` 而**不是 404**:客户端问的是「这里能用什么引擎」,
不是「这个接口存在吗」。一个 `TTS_URL` 没配的实例是完全正常的实例。
- `maxLength` 无论开关都返回:客户端会按它切句,这个决定发生在选引擎之前。
- 配置了之后 `voices` 来自 `TTS_VOICES_URL`(或约定路径 `<TTS_URL>/voices`)。
### `GET /tts?text=<文本>&voice=<可选>&speed=<可选>&format=<可选>`
合成一条语句,返回音频字节流(`audio/*`)。
- **一次一条语句,不是一章**。客户端本来就是逐句朗读(理由见架构文档第 9 节),
而按请求切分正是让暂停、续读、下一句、改语速在远程引擎上也能工作的前提;
它同时把响应限制在手机可以边走边缓冲的大小,并让缓存以句为单位而不是以书为单位。
- `speed` 夹在 0.25–4;`text` 上限 800 字,超了返回 `TEXT_TOO_LONG`,
**不会转发给上游**。
- 上游返回非 `audio/*` 时返回 `TTS_UPSTREAM` 而不是把 HTML 当音频流出去:
一个配错的 `TTS_URL`(指到一个网页)会以 200 返回 HTML,塞进 `<audio>` 里
既没有声音也没有事件——这是最难排查的一种「成功」。
- 这是唯一接受**查询串令牌**的接口之一(另一个是 `/books/:id/assets`、`/cover`、`/content`):
`<audio src>` 无法携带 `Authorization` 头。因此它被精确地限制为 `GET /tts`,
能力查询 `/tts/voices` 不支持查询串令牌。
- 响应带 `cache-control: private, max-age=604800, immutable`:
同一句话的音频永远相同,第二次播放不应该再到服务端。
相关环境变量:
| 变量 | 说明 |
| --- | --- |
| `TTS_URL` | 上游合成服务的地址,例如 `http://127.0.0.1:5002/tts`。不设则关闭 HTTP 朗读 |
| `TTS_TOKEN` | 以 `Authorization: Bearer` 转发给上游 |
| `TTS_VOICES_URL` | 语音列表地址;默认 `<TTS_URL>/voices` |
| `TTS_TIMEOUT_MS` | 单次合成超时,默认 20000 |
| `TTS_CACHE_BYTES` | 音频磁盘缓存上限(`DATA_DIR/tts-cache`),默认 256MiB;0 关闭缓存 |
## 静态客户端
### `GET /` 与 `GET /*` — 公开
@@ -477,6 +521,9 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
| `FILE_MISSING` | 404 | 文件已从磁盘消失 |
| `USERNAME_TAKEN` | 409 | 用户名已占用 |
| `PASSWORD_TOO_SHORT` | 409 | 口令少于 8 位 |
| `TTS_DISABLED` | 400 | 该实例没有配置 `TTS_URL`,无法使用 HTTP 朗读 |
| `TTS_UPSTREAM` | 400 | 上游合成服务不可达、超时、报错,或返回的不是音频 |
| `TEXT_TOO_LONG` | 400 | 单条语句超过 800 字,请客户端先切句 |
| `INTERNAL` | 500 | 服务端错误 |
## 客户端应当遵守的约定
+49
View File
@@ -389,6 +389,46 @@ Android 上经典的「页面自己往边上飘」bug。
亮度做成了 `mix-blend-mode: multiply` 的覆盖层而不是调色板:换主题应该保留
纸与字的对比关系,直接改颜色会把文字一起压暗,那正是「亮度」滑杆最常见的失败。
### 三个朗读引擎,一个队列
朗读有三条来源,而客户端的其余部分不需要知道正在用哪一条:
| 引擎 | 声音来自 | 什么时候用 |
| --- | --- | --- |
| `native` | Android 外壳的 `TextToSpeech` | 有 `speech` 接口的外壳(版本 ≥ 3) |
| `system` | WebView / 浏览器的 `speechSynthesis` | 默认。免费、离线、无往返 |
| `http` | 服务端的 `TTS_URL`,经 `/api/v1/tts` 代理 | 前两者都不可用时 |
`auto` 的优先级是 native → system → http,理由不是「谁更好」而是**谁知道得更多**:
- native 排在 system 前面,尽管 WebView 自己也有合成器。因为那是**两个不同的引擎**:
手机系统装好的中文语音经常不在 WebView 的 `getVoices()` 列表里,
于是读者听到英文或听不到声音,而 Web 层拿到的列表本身就是缺的,修不了。
这是「朗读没有中文声音」最常见的原因,唯一的解法是直接问平台。
- http 排最后,因为它的每一句都要一次请求,而且它需要一个配了 `TTS_URL` 的服务端。
三者的**暂停语义真的不同**,所以它们不是同一个基类:
- `speechSynthesis` 能暂停,`TextToSpeech` 不能(只能 stop),`<audio>` 能暂停但要重播整句。
- 改语速:`<audio>` 直接改 `playbackRate`(不用重读),另两个必须重读当前句。
- 音调:`<audio>` 根本没有,所以设置面板在 HTTP 引擎下**不显示这一行**,
而不是显示一个按了没反应的滑块。
派生的差异一并被显式处理:HTTP 引擎的单句上限是 800 字,而一段没有标点的中文段落
可能有两千字——切分发生在 `rememberSpeechChunks()` 里,只对引擎生效,
高亮仍然锚定在原来的整句上(碎片带着原句的 `start` 偏移)。
### Android 的原生朗读接口为什么被拆成第二个对象
`addJavascriptInterface` 只能注册**对象**,不能注册某个对象上的属性,
所以 `window.ReaderAndroid.speech` 实际上是 `window.ReaderAndroidSpeech` 这个独立注册的接口,
由 `promoteSpeechInterface()` 在 Web 层重新挂回第一个对象上。
契约(`android-bridge.d.ts`)对外只有一个嵌套对象,接线方式不出现在这一层之上。
和原生整页渲染一样,它按**外壳版本**判断而不是按方法存在与否判断:
版本低于 3 的外壳就是没有这个接口,客户端退回 `speechSynthesis`,
而不是到播放时才发现方法不存在。
### 朗读为什么按句排队
`speechSynthesis` 本身只有一行 API,难的是决定**下一句说什么**。按句而不是按段:
@@ -420,6 +460,15 @@ Android 上经典的「页面自己往边上飘」bug。
队列耗尽时引擎再问一次,屏幕层决定是「取下一章」还是「到此为止」。
这个分工让「读到章末停下」成为设置项而不是引擎里的分支。
队列的推进由事件驱动,绝不靠计时器:`speechSynthesis` 的 `onend`、`<audio>` 的 `ended`、
原生外壳的 `done`。计时器会漂移,而一次漂移就意味着两句同时被说出来或一句被吞掉。
每个引擎还各有一个看门狗,只在**引擎自己彻底安静了**(长时间熄屏后的
Android WebView 是真实会发生的)才推进,不是为了给朗读计时。
原生引擎的事件带一个**客户端自己生成的 utterance id**,外壳原样回传。
没有它,就分不清「我正在读的这句读完了」和「读者已经跳过的那句读完了」——
后者会让光标多走一格,也就是跳一句。
屏幕层还有一个必须走的公共路径:所有翻页都经过 `turnPage()`。原因是引擎锚定在
**当前章节的 DOM 节点**上,绕过这个方法的翻页会让引擎对着一个已经脱离文档的节点
继续朗读——听起来就像客户端在自言自语。
+2
View File
@@ -4,6 +4,7 @@ import { registerErrorHandler } from './errors.ts';
import { registerAuthRoutes } from './routes/auth.ts';
import { registerLibraryRoutes } from './routes/library.ts';
import { registerSyncRoutes } from './routes/sync.ts';
import { registerTtsRoutes } from './routes/tts.ts';
import { registerWebRoutes } from './routes/web.ts';
import { isOriginAllowed, resolveCorsOrigin } from './cors.ts';
@@ -37,6 +38,7 @@ export function buildApp(ctx: AppContext): FastifyInstance {
registerAuthRoutes(app, ctx);
registerLibraryRoutes(app, ctx);
registerSyncRoutes(app, ctx);
registerTtsRoutes(app, ctx);
// Registered last: the SPA fallback must not shadow an API route.
registerWebRoutes(app, ctx);
+7
View File
@@ -52,6 +52,13 @@ function bearerToken(request: FastifyRequest): string | null {
function queryTokenAllowed(request: FastifyRequest): boolean {
if (request.method !== 'GET') return false;
const path = (request.url.split('?')[0] ?? '').replace(/\/$/, '');
// Speech, for the same reason: it is a resource an `<audio>` element fetches
// itself. The allowlist is exact rather than a prefix, because the capability
// route (`/tts/voices`) is an API call and must carry a header — the exposure
// here is a read of audio derived from a sentence the client already holds.
if (path === '/api/v1/tts') return true;
if (!path.startsWith('/api/v1/books/')) return false;
return path.endsWith('/assets') || path.endsWith('/cover') || path.endsWith('/content');
}
+2
View File
@@ -5,6 +5,7 @@ import type { Scanner } from '../indexer/scanner.ts';
import type { UserService, PublicUser } from '../services/users.ts';
import type { ShelfService } from '../services/shelf.ts';
import type { SyncService } from '../services/sync.ts';
import type { TtsService } from '../services/tts.ts';
export interface AppContext {
config: AppConfig;
@@ -13,6 +14,7 @@ export interface AppContext {
users: UserService;
shelf: ShelfService;
sync: SyncService;
tts: TtsService;
log: FastifyBaseLogger;
}
+61
View File
@@ -0,0 +1,61 @@
import type { FastifyInstance } from 'fastify';
import type { AppContext } from '../context.ts';
import { authenticate } from '../auth.ts';
import { badRequest } from '../../lib/errors.ts';
/**
* Speech synthesis over HTTP.
*
* Two routes, and the split between them is the whole design:
*
* - `GET /api/v1/tts/voices` is the *capability* answer. A client asks it once
* when the reader opens the朗读 settings, and uses the reply to decide which
* engines to offer. An instance with no `TTS_URL` answers `{ http: false }`
* rather than 404, because the client's question is "what can I use here",
* not "does this endpoint exist".
* - `GET /api/v1/tts` is the *audio* answer, one utterance at a time.
*
* One utterance per request, not one chapter, and that is not an accident: the
* client already speaks sentence by sentence (see `web/src/render/tts.ts` for
* why), and a per-request boundary is what makes pause, resume, next-sentence
* and rate changes work against a remote engine at all. It also bounds the
* response to something a phone can buffer while walking, and lets the server
* cache per sentence instead of per book.
*
* The access token travels in the query string on this route, like `/assets` and
* `/cover`. An `<audio src>` cannot carry an `Authorization` header, and a fetch
* + Blob URL would defeat the browser's own media buffering — which is the one
* thing an audio element is genuinely better at than any hand-rolled player.
*/
export function registerTtsRoutes(app: FastifyInstance, ctx: AppContext): void {
const auth = authenticate(ctx);
app.get('/api/v1/tts/voices', { preHandler: auth }, async () => {
const capabilities = ctx.tts.capabilities();
if (!ctx.tts.enabled) return capabilities;
return { ...capabilities, voices: await ctx.tts.voices() };
});
app.get('/api/v1/tts', { preHandler: auth }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>;
const text = query.text ?? '';
if (!text.trim()) throw badRequest('text is required');
const speed = query.speed !== undefined ? Number.parseFloat(query.speed) : undefined;
if (speed !== undefined && !Number.isFinite(speed)) throw badRequest('speed must be a number');
const audio = await ctx.tts.synthesize({
text,
...(query.voice ? { voice: query.voice } : {}),
...(speed !== undefined ? { speed } : {}),
...(query.format ? { format: query.format } : {}),
});
reply.header('content-type', audio.contentType);
reply.header('content-length', String(audio.bytes.byteLength));
// Immutable per (text, voice, speed): the same sentence will always be the
// same audio, so a second play of it should not reach the server at all.
reply.header('cache-control', 'private, max-age=604800, immutable');
reply.header('x-tts-cache', audio.cached ? 'hit' : 'miss');
return reply.send(Buffer.from(audio.bytes));
});
}
+2
View File
@@ -4,6 +4,7 @@ import { Scanner } from './indexer/scanner.ts';
import { UserService } from './services/users.ts';
import { ShelfService } from './services/shelf.ts';
import { SyncService } from './services/sync.ts';
import { TtsService } from './services/tts.ts';
import { buildApp } from './http/app.ts';
import { setLogger } from './lib/log.ts';
import type { AppContext } from './http/context.ts';
@@ -45,6 +46,7 @@ async function main(): Promise<void> {
ctx.users = new UserService(db, config);
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db);
ctx.tts = new TtsService(config);
app.log.info({ booksDir: config.booksDir, dataDir: config.dataDir }, 'starting reader server');
+22 -1
View File
@@ -22,11 +22,28 @@ export interface BookDto {
source: string;
/** Which fields the user overrode by hand; lets the UI offer an undo. */
manualFields: string[];
/**
* When the file last changed on disk.
*
* Distinct from `addedAt`, and the shelf offers both: "最近更新" answers "which
* book did I just replace with a better scan", "最近入库" answers "which book did
* I just acquire". Collapsing them would answer neither.
*/
updatedAt: number;
/**
* When this account first saw the book.
*
* Exposed because the client sorts its own cached shelf while offline, and a
* sort key it does not have is a sort it cannot reproduce — which would make the
* order change the moment a phone lost its connection.
*/
addedAt: number;
}
interface BookRow {
id: string;
/** `user_books.added_at`; selected alongside the book's own columns. */
added_at?: number;
identifier: string | null;
content_hash: string;
format: string;
@@ -115,7 +132,7 @@ export class ShelfService {
);
const rows = this.db.all<BookRow>(
`SELECT ${BOOK_COLUMNS.split(',').map((c) => `b.${c.trim()}`).join(', ')}
`SELECT ${BOOK_COLUMNS.split(',').map((c) => `b.${c.trim()}`).join(', ')}, ub.added_at
FROM books b JOIN user_books ub ON ub.book_id = b.id
WHERE ${whereSql}
ORDER BY ${sortColumn} ${order}, b.title ASC
@@ -179,6 +196,10 @@ export class ShelfService {
source: effective.source,
manualFields: appliedFields,
updatedAt: row.updated_at,
// A row read outside `list` (a single-book lookup) has no `user_books`
// join, so the fallback is the book's own timestamp rather than 0 — a zero
// would sort every such book to the bottom of "最近入库".
addedAt: row.added_at ?? row.updated_at,
};
}
+342
View File
@@ -0,0 +1,342 @@
import { badRequest } from '../lib/errors.ts';
import type { AppConfig } from '../config/index.ts';
import type { AppContext } from '../http/context.ts';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
unlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
/**
* Server-side speech synthesis, for a client that has no engine of its own.
*
* ## Why this is here at all
*
* The web client synthesises with the device's own `speechSynthesis`, which is
* free, offline and already installed. That works on Chrome, Edge and Android's
* WebView — and does not work at all in a browser without the API, or when the
* device has no Chinese voice installed, which is the normal case on a Linux
* desktop. An HTTP engine is the only way for those readers to listen to a book
* without shipping a neural model in the container.
*
* ## What this server will and will not do
*
* It **proxies**, it does not synthesise. `TTS_URL` points at any service that
* accepts the classic `?text=...&speed=...` shape — edge-tts, a local Piper
* wrapper, a commercial API with a compatibility shim — and the bytes are
* streamed straight back. Bundling a synthesizer would multiply the image size
* and the CPU budget of a NAS, which is exactly the trade the client side of
* this project refuses to make.
*
* The proxy is opt-in (`TTS_URL` unset means "no HTTP engine") and it is
* authenticated like the rest of the API: an open relay from a self-hosted
* server to a third-party TTS service is someone else's bill.
*/
/** Longest utterance the client is allowed to ask for, in characters. */
const MAX_TEXT_LENGTH = 800;
/** What the client is told this instance supports. */
export interface TtsCapabilities {
/** True when `TTS_URL` is configured, i.e. this instance can synthesise. */
http: boolean;
/** Content types the proxy may answer with, for the client's own checks. */
formats: string[];
/** Longest single utterance, so the client can split before asking. */
maxLength: number;
/** The service's own voice list, when it publishes one. */
voices: TtsVoice[];
}
export interface TtsVoice {
id: string;
name: string;
lang: string;
}
interface TtsConfig {
url: string;
token: string;
voicesUrl: string;
timeoutMs: number;
cacheDir: string;
cacheMaxBytes: number;
}
/**
* Disk cache of synthesised audio.
*
* The same sentence is spoken more than once in real use — pausing and resuming
* re-speaks, "上一句" re-speaks, and a re-open of the same chapter re-speaks the
* whole queue — and every one of those would otherwise be a fresh round trip to
* a remote service. A hash-addressed file is the whole implementation.
*
* Size is bounded by evicting oldest-first, because a self-hosted box's data
* directory is shared with the database and the cover cache.
*/
export class TtsService {
private readonly config: TtsConfig | null;
private cacheBytes = 0;
private cacheFiles: string[] = [];
constructor(config: AppConfig) {
const url = (process.env.TTS_URL ?? '').trim().replace(/\/+$/, '');
this.config = url
? {
url,
token: (process.env.TTS_TOKEN ?? '').trim(),
voicesUrl: (process.env.TTS_VOICES_URL ?? '').trim(),
timeoutMs: Number.parseInt(process.env.TTS_TIMEOUT_MS ?? '20000', 10) || 20_000,
cacheDir: join(config.dataDir, 'tts-cache'),
cacheMaxBytes: Number.parseInt(process.env.TTS_CACHE_BYTES ?? String(256 * 1024 * 1024), 10) || 0,
}
: null;
if (this.config) this.loadCacheIndex();
}
/** Whether this instance can synthesise at all. */
get enabled(): boolean {
return this.config !== null;
}
capabilities(): TtsCapabilities {
return {
http: this.enabled,
formats: ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/mp4'],
maxLength: MAX_TEXT_LENGTH,
voices: [],
};
}
/**
* Synthesises one utterance.
*
* Returns the audio bytes and their content type. A voice is passed through
* opaquely: what a "voice id" means is the upstream service's business, and
* this server has no business pretending to know.
*/
async synthesize(input: {
text: string;
voice?: string;
speed?: number;
format?: string;
}): Promise<{ bytes: Uint8Array; contentType: string; cached: boolean }> {
const config = this.config;
if (!config) {
throw badRequest('this instance has no HTTP TTS engine configured (set TTS_URL)', 'TTS_DISABLED');
}
const text = (input.text ?? '').trim();
if (!text) throw badRequest('text is required');
if (text.length > MAX_TEXT_LENGTH) {
throw badRequest(`text is longer than ${MAX_TEXT_LENGTH} characters`, 'TEXT_TOO_LONG');
}
// A speed outside this range is not a preference, it is a mistake that would
// make the service either chipmunk or unusable.
const speed = input.speed === undefined ? 1 : clamp(input.speed, 0.25, 4);
const voice = (input.voice ?? '').slice(0, 120);
const key = hashKey({ text, voice, speed, format: input.format ?? '', url: config.url });
const cached = this.readCache(key);
if (cached) return { ...cached, cached: true };
const target = this.buildUrl({ text, voice, speed, ...(input.format ? { format: input.format } : {}) });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
let response: Response;
try {
response = await fetch(target, {
method: 'GET',
headers: {
accept: 'audio/*',
...(config.token ? { authorization: `Bearer ${config.token}` } : {}),
},
signal: controller.signal,
});
} catch (err) {
const message = err instanceof Error && err.name === 'AbortError' ? 'timed out' : 'unreachable';
throw badRequest(`HTTP TTS service ${message}`, 'TTS_UPSTREAM');
} finally {
clearTimeout(timer);
}
if (!response.ok) {
throw badRequest(`HTTP TTS service answered ${response.status}`, 'TTS_UPSTREAM');
}
const contentType = (response.headers.get('content-type') ?? 'audio/mpeg').split(';')[0]!.trim();
// A service that answers with HTML is a misconfiguration (a wrong URL landing
// on a web page), and streaming that into an `<audio>` element produces the
// most confusing possible failure: silence with a 200.
if (!contentType.startsWith('audio/')) {
throw badRequest(`HTTP TTS service returned ${contentType}, expected audio`, 'TTS_UPSTREAM');
}
const bytes = new Uint8Array(await response.arrayBuffer());
this.writeCache(key, bytes, contentType);
return { bytes, contentType, cached: false };
}
/** The upstream voice list, when the service publishes one. */
async voices(): Promise<TtsVoice[]> {
const config = this.config;
if (!config) return [];
// `/voices` is the convention edge-tts and its clones use; anything else is
// configured explicitly rather than guessed.
const url = config.voicesUrl || `${config.url}/voices`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.min(config.timeoutMs, 5000));
try {
const response = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json',
...(config.token ? { authorization: `Bearer ${config.token}` } : {}),
},
signal: controller.signal,
});
if (!response.ok) return [];
const body = (await response.json()) as unknown;
const rows = Array.isArray(body) ? body : ((body as { voices?: unknown[] }).voices ?? []);
return rows
.map((row) => normaliseVoice(row))
.filter((voice): voice is TtsVoice => voice !== null)
.slice(0, 400);
} catch {
// A missing voice list is not an error: the client falls back to whatever
// the reader typed or to the service's own default.
return [];
} finally {
clearTimeout(timer);
}
}
/**
* The upstream URL.
*
* `text` is sent as a query parameter because that is what the compatible
* services expect. `URLSearchParams` handles the encoding, which matters
* immediately: a Chinese sentence in a URL is a percent-encoded byte sequence
* that a hand-built string would mangle.
*/
private buildUrl(input: { text: string; voice: string; speed: number; format?: string }): string {
const config = this.config!;
const params = new URLSearchParams();
params.set('text', input.text);
if (input.voice) params.set('voice', input.voice);
params.set('speed', String(input.speed));
if (input.format) params.set('format', input.format);
// `TTS_URL` may already carry its own query (an API key, a model name), so
// the separator is chosen rather than assumed.
const separator = config.url.includes('?') ? '&' : '?';
return `${config.url}${separator}${params.toString()}`;
}
// ---- cache ----
private loadCacheIndex(): void {
const dir = this.config!.cacheDir;
try {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
this.cacheFiles = [];
this.cacheBytes = 0;
for (const name of readdirSync(dir)) {
const stat = statSync(join(dir, name));
this.cacheFiles.push(name);
this.cacheBytes += stat.size;
}
} catch {
this.cacheFiles = [];
this.cacheBytes = 0;
}
}
private readCache(key: string): { bytes: Uint8Array; contentType: string } | null {
const config = this.config;
if (!config) return null;
const meta = join(config.cacheDir, `${key}.meta`);
const data = join(config.cacheDir, `${key}.bin`);
try {
if (!existsSync(data) || !existsSync(meta)) return null;
// Touching the mtime on every hit is what makes "oldest first" eviction a
// least-recently-*used* policy rather than a least-recently-written one.
const now = Date.now() / 1000;
try {
utimesSync(data, now, now);
} catch {
// Best effort: an unreadable mtime only makes eviction less accurate.
}
return { bytes: new Uint8Array(readFileSync(data)), contentType: readFileSync(meta, 'utf8') || 'audio/mpeg' };
} catch {
return null;
}
}
private writeCache(key: string, bytes: Uint8Array, contentType: string): void {
const config = this.config;
if (!config || config.cacheMaxBytes <= 0) return;
try {
writeFileSync(join(config.cacheDir, `${key}.bin`), bytes);
writeFileSync(join(config.cacheDir, `${key}.meta`), contentType);
this.cacheBytes += bytes.byteLength;
this.cacheFiles.push(`${key}.bin`, `${key}.meta`);
this.evict();
} catch {
// A full disk must not fail the request that was already answered.
}
}
private evict(): void {
const config = this.config!;
if (this.cacheBytes <= config.cacheMaxBytes) return;
let entries: Array<{ name: string; size: number; mtime: number }> = [];
try {
entries = readdirSync(config.cacheDir).map((name) => {
const stat = statSync(join(config.cacheDir, name));
return { name, size: stat.size, mtime: stat.mtimeMs };
});
} catch {
return;
}
entries.sort((a, b) => a.mtime - b.mtime);
for (const entry of entries) {
if (this.cacheBytes <= config.cacheMaxBytes * 0.8) break;
try {
unlinkSync(join(config.cacheDir, entry.name));
this.cacheBytes -= entry.size;
} catch {
// Already gone.
}
}
}
}
function normaliseVoice(row: unknown): TtsVoice | null {
if (typeof row === 'string') return { id: row, name: row, lang: '' };
if (typeof row !== 'object' || row === null) return null;
const record = row as Record<string, unknown>;
const id = String(record['id'] ?? record['ShortName'] ?? record['name'] ?? '');
if (!id) return null;
return {
id,
name: String(record['name'] ?? record['FriendlyName'] ?? id),
lang: String(record['lang'] ?? record['locale'] ?? record['Language'] ?? ''),
};
}
function hashKey(input: Record<string, unknown>): string {
return createHash('sha256').update(JSON.stringify(input)).digest('hex').slice(0, 32);
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return 1;
return Math.max(min, Math.min(max, value));
}
/** Wired in `main.ts`; kept out of `AppContext` so tests can build one freely. */
export function createTtsService(ctx: AppContext): TtsService {
return new TtsService(ctx.config);
}
+3
View File
@@ -19,6 +19,7 @@ import { Scanner } from '../src/indexer/scanner.ts';
import { UserService } from '../src/services/users.ts';
import { ShelfService } from '../src/services/shelf.ts';
import { SyncService } from '../src/services/sync.ts';
import { TtsService } from '../src/services/tts.ts';
import { buildApp } from '../src/http/app.ts';
import type { AppContext } from '../src/http/context.ts';
@@ -66,6 +67,7 @@ before(async () => {
users: undefined as never,
shelf: undefined as never,
sync: undefined as never,
tts: undefined as never,
log: undefined as never,
};
const silent = Fastify({ logger: false });
@@ -74,6 +76,7 @@ before(async () => {
ctx.users = new UserService(db, config);
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db);
ctx.tts = new TtsService(config);
app = buildApp(ctx);
await app.ready();
});
+271
View File
@@ -0,0 +1,271 @@
/**
* HTTP TTS tests.
*
* The proxy has two failure modes that are worse than an error, and both are
* asserted here:
*
* - **Silence with a 200.** A misconfigured `TTS_URL` (a wrong host landing on
* a web page) answers `text/html` with a status of 200. Streaming that into an
* `<audio>` element plays nothing and reports nothing, so the proxy refuses any
* content type that is not audio.
* - **An open relay.** `TTS_URL` is a URL the *server* holds, so a client must
* never be able to point the request somewhere else, and the route must be
* authenticated so a stray instance cannot spend someone else's quota.
*
* A stub upstream is used rather than a real service: the contract being tested
* is *this* proxy's behaviour, and a network dependency in a unit test is a test
* that fails on a train.
*/
import { test, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, type Server } from 'node:http';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { loadConfig } from '../src/config/index.ts';
import { openDatabase } from '../src/db/index.ts';
import { Scanner } from '../src/indexer/scanner.ts';
import { UserService } from '../src/services/users.ts';
import { ShelfService } from '../src/services/shelf.ts';
import { SyncService } from '../src/services/sync.ts';
import { TtsService } from '../src/services/tts.ts';
import { buildApp } from '../src/http/app.ts';
import type { AppContext } from '../src/http/context.ts';
let root: string;
let app: FastifyInstance;
let ctx: AppContext;
let token = '';
let upstream: Server | null = null;
let upstreamUrl = '';
/** What the stub upstream should answer with on the next call. */
let upstreamBehaviour: 'audio' | 'html' | 'error' = 'audio';
let lastUpstreamQuery = '';
/** Minimal stand-in for an edge-tts-compatible service. */
function startUpstream(): Promise<void> {
return new Promise((resolve) => {
upstream = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
lastUpstreamQuery = url.search;
if (url.pathname === '/voices') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify([{ id: 'zh-CN-XiaoxiaoNeural', name: '晓晓', lang: 'zh-CN' }]));
return;
}
if (upstreamBehaviour === 'html') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end('<html><body>not audio</body></html>');
return;
}
if (upstreamBehaviour === 'error') {
res.writeHead(502, { 'content-type': 'text/plain' });
res.end('bad gateway');
return;
}
// A recognisable, deterministic body: the tests assert the bytes came from
// upstream rather than being invented here.
res.writeHead(200, { 'content-type': 'audio/mpeg' });
res.end(Buffer.from(`AUDIO:${url.searchParams.get('text') ?? ''}`));
});
upstream.listen(0, '127.0.0.1', () => {
const address = upstream!.address();
const port = typeof address === 'object' && address ? address.port : 0;
upstreamUrl = `http://127.0.0.1:${port}/tts`;
// Same origin, different path: the convention the docs describe, made
// explicit because a proxy that *guesses* the voice path is a proxy that
// asks a stranger's server for a URL nobody configured.
process.env.TTS_VOICES_URL = `http://127.0.0.1:${port}/voices`;
resolve();
});
});
}
async function buildInstance(): Promise<void> {
const config = loadConfig();
const db = openDatabase(config);
ctx = {
config,
db,
scanner: undefined as never,
users: undefined as never,
shelf: undefined as never,
sync: undefined as never,
tts: undefined as never,
log: undefined as never,
};
const silent = Fastify({ logger: false });
ctx.log = silent.log;
ctx.scanner = new Scanner(db, config, { info: () => {}, warn: () => {} });
ctx.users = new UserService(db, config);
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db);
ctx.tts = new TtsService(config);
app = buildApp(ctx);
await app.ready();
}
before(async () => {
root = await mkdtemp(join(tmpdir(), 'reader-tts-test-'));
process.env.BOOKS_DIR = join(root, 'books');
process.env.DATA_DIR = join(root, 'data');
process.env.SCAN_INTERVAL = '0';
process.env.WATCH_INTERVAL = '0';
process.env.READER_TOKEN_SECRET = 'test-secret-that-is-long-enough';
process.env.TTS_CACHE_BYTES = '0';
await startUpstream();
});
after(async () => {
await app?.close();
await new Promise<void>((resolve) => upstream?.close(() => resolve()));
await rm(root, { recursive: true, force: true });
});
/** Rebuilds the instance so the environment (`TTS_URL`) is read fresh. */
beforeEach(async () => {
await app?.close();
await buildInstance();
if (!token) {
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/register',
payload: { username: 'tts-user', password: 'password123' },
});
assert.equal(res.statusCode, 201, res.body);
token = (res.json() as { session: { accessToken: string } }).session.accessToken;
}
});
test('an instance with no TTS_URL reports http:false rather than 404', async () => {
delete process.env.TTS_URL;
await app.close();
await buildInstance();
const res = await app.inject({ method: 'GET', url: '/api/v1/tts/voices', headers: auth() });
assert.equal(res.statusCode, 200);
const body = res.json() as { http: boolean; maxLength: number };
assert.equal(body.http, false);
// The ceiling is advertised whether or not the proxy is on: the client splits
// sentences against it before deciding which engine to use.
assert.equal(body.maxLength, 800);
});
test('synthesis is refused with a clear error when TTS_URL is unset', async () => {
delete process.env.TTS_URL;
await app.close();
await buildInstance();
const res = await app.inject({
method: 'GET',
url: `/api/v1/tts?text=${encodeURIComponent('你好')}&access_token=${token}`,
});
assert.equal(res.statusCode, 400);
assert.equal((res.json() as { error: { code: string } }).error.code, 'TTS_DISABLED');
});
test('a sentence is proxied to the upstream service and streamed back as audio', async () => {
process.env.TTS_URL = upstreamUrl;
await app.close();
await buildInstance();
upstreamBehaviour = 'audio';
const res = await app.inject({
method: 'GET',
url: `/api/v1/tts?text=${encodeURIComponent('床前明月光。')}&speed=1.2&access_token=${token}`,
});
assert.equal(res.statusCode, 200, res.body);
assert.equal(res.headers['content-type'], 'audio/mpeg');
assert.equal(res.body, 'AUDIO:床前明月光。');
// The query is built with `URLSearchParams`, so a Chinese sentence survives the
// trip; a hand-built URL would have sent mojibake.
assert.match(lastUpstreamQuery, /text=%E5%BA%8A%E5%89%8D%E6%98%8E%E6%9C%88%E5%85%89/);
assert.match(lastUpstreamQuery, /speed=1\.2/);
});
test('an upstream that answers HTML is refused instead of served as silence', async () => {
process.env.TTS_URL = upstreamUrl;
await app.close();
await buildInstance();
upstreamBehaviour = 'html';
const res = await app.inject({
method: 'GET',
url: `/api/v1/tts?text=hello&access_token=${token}`,
});
assert.equal(res.statusCode, 400);
assert.equal((res.json() as { error: { code: string } }).error.code, 'TTS_UPSTREAM');
});
test('an upstream error is reported as such, not as a 200', async () => {
process.env.TTS_URL = upstreamUrl;
await app.close();
await buildInstance();
upstreamBehaviour = 'error';
const res = await app.inject({
method: 'GET',
url: `/api/v1/tts?text=hello&access_token=${token}`,
});
assert.equal(res.statusCode, 400);
assert.equal((res.json() as { error: { code: string } }).error.code, 'TTS_UPSTREAM');
});
test('an over-long utterance is refused before it reaches the upstream', async () => {
process.env.TTS_URL = upstreamUrl;
await app.close();
await buildInstance();
upstreamBehaviour = 'audio';
lastUpstreamQuery = '';
const res = await app.inject({
method: 'GET',
url: `/api/v1/tts?text=${encodeURIComponent('长'.repeat(900))}&access_token=${token}`,
});
assert.equal(res.statusCode, 400);
assert.equal((res.json() as { error: { code: string } }).error.code, 'TEXT_TOO_LONG');
assert.equal(lastUpstreamQuery, '', 'the upstream must not be called at all');
});
test('the voice list is passed through from the upstream service', async () => {
process.env.TTS_URL = upstreamUrl;
await app.close();
await buildInstance();
const res = await app.inject({ method: 'GET', url: '/api/v1/tts/voices', headers: auth() });
assert.equal(res.statusCode, 200);
const body = res.json() as { http: boolean; voices: Array<{ id: string; lang: string }> };
assert.equal(body.http, true);
assert.deepEqual(body.voices, [{ id: 'zh-CN-XiaoxiaoNeural', name: '晓晓', lang: 'zh-CN' }]);
});
test('the audio route requires a token, in the header or the query', async () => {
process.env.TTS_URL = upstreamUrl;
await app.close();
await buildInstance();
const anonymous = await app.inject({ method: 'GET', url: '/api/v1/tts?text=hello' });
assert.equal(anonymous.statusCode, 401);
// A query token is allowed on this route only because an `<audio src>` cannot
// carry a header. Anything else must use the header.
const viaQuery = await app.inject({ method: 'GET', url: `/api/v1/tts?text=hello&access_token=${token}` });
assert.equal(viaQuery.statusCode, 200);
const viaHeader = await app.inject({ method: 'GET', url: '/api/v1/tts?text=hello', headers: auth() });
assert.equal(viaHeader.statusCode, 200);
// The capability route is an API call, so a query token is not accepted there.
const capabilityWithQuery = await app.inject({
method: 'GET',
url: `/api/v1/tts/voices?access_token=${token}`,
});
assert.equal(capabilityWithQuery.statusCode, 401);
});
function auth(): Record<string, string> {
return { authorization: `Bearer ${token}` };
}
+86
View File
@@ -54,11 +54,97 @@ export interface AndroidBridge {
* take the reader out of the app.
*/
canOpenDocument(mediaType: string): boolean;
/**
* The native speech surface, if this shell has one.
*
* A nested object rather than more top-level methods because it is a *feature*
* rather than a capability: a shell without it is a perfectly good shell, and
* the client falls back to `speechSynthesis` (which is present in every
* Android WebView). Keeping it nested also means the feature check is one
* property read rather than seven `typeof` checks.
*
* Added in shell version 3. Note that on the platform side this is a *separate*
* registered interface (`window.ReaderAndroidSpeech`) — `addJavascriptInterface`
* can expose objects, not properties of one — and the web layer re-parents it
* onto this key in `android-platform.ts`. Declaring it here as a property is
* therefore the contract the client sees, not the shape Kotlin registers.
*/
speech?: SpeechBridge;
}
/**
* Android's `TextToSpeech`, exposed to the client.
*
* This exists because a WebView's own `speechSynthesis` is a *different*
* synthesizer from the one the operating system uses, and on a phone it is
* frequently the worse of the two: Chinese voices installed for the OS are
* routinely absent from `getVoices()`, which is why "朗读没有中文声音" is the most
* common complaint about browser-based TTS on Android.
*
* The queue, the cursor and the chapter boundary stay in JavaScript. This is only
* "say this sentence" and "tell me when you have said it" — a native queue would
* be a second implementation of rules that already exist in one place.
*/
export interface SpeechBridge {
/** Whether the platform engine initialised successfully. */
available(): boolean;
/** Creates the engine and loads voices. Idempotent. */
init(): void;
/**
* Registers the callback invoked for `start` / `done` / `error` events.
*
* `id` is the utterance the event is about, and it is not decoration: a `done`
* for a sentence the reader has already moved past must not advance the client's
* cursor, and without an id there is no way to tell the two apart. The id is
* chosen by the client and echoed back by the shell (see `speak`).
*/
onSpeechEvent(callback: (event: { type: string; id?: string; message?: string }) => void): void;
/** Registers the callback that receives the voice list, JSON-encoded. */
onVoices(callback: (voices: Array<{ id: string; name: string; lang: string; default?: boolean }>) => void): void;
/**
* Speaks one utterance. Replaces anything already in flight.
*
* The id is the client's, not the shell's: the client knows which sentence it
* asked for, and it is the only side that can decide whether a late event still
* belongs to the sentence on screen.
*/
speak(text: string, utteranceId: string): void;
pause(): void;
stop(): void;
setRate(rate: number): void;
setPitch(pitch: number): void;
setVolume(volume: number): void;
setVoice(voiceId: string): void;
/**
* Releases the engine and unbinds the platform service.
*
* Not "stop": stopping leaves the engine ready for the next sentence, and this
* is what the shell calls when the activity is destroyed. On Android it is the
* difference between a bound service being released and being leaked.
*/
shutdown(): void;
}
/**
* The subset of `SpeechBridge` the shared layer depends on.
*
* Declared as an alias so `speech.ts` can take one as a parameter without
* importing the whole bridge contract, and so a test double only has to
* implement the sentence-at-a-time part.
*/
export type NativeSpeechBridge = SpeechBridge;
declare global {
interface Window {
ReaderAndroid?: AndroidBridge;
/**
* The speech interface, registered separately by the shell.
*
* Present only on shell version 3 and above. Read by
* `promoteSpeechInterface`, which is the one place that knows about it.
*/
ReaderAndroidSpeech?: SpeechBridge;
}
}
+33
View File
@@ -24,12 +24,34 @@ export interface SessionStore {
clear(): Promise<void>;
}
/**
* What this instance says it can synthesise.
*
* `http: false` is the normal answer for a deployment with no `TTS_URL`, and it
* is deliberately a 200 rather than a 404: the client's question is "which
* engines may I offer here", not "does this endpoint exist".
*/
export interface TtsCapabilities {
http: boolean;
formats: string[];
maxLength: number;
voices: Array<{ id: string; name: string; lang: string }>;
}
export interface ListQuery {
search?: string;
author?: string;
series?: string;
tag?: string;
format?: string;
/**
* Sort key.
*
* `updated` is what the shelf has always used and stays the default: a library
* is browsed by "what did I just add", and a reader who wants A–Z can ask for
* `title`. The list is the server's own `ListOptions.sort`, so the two cannot
* drift.
*/
sort?: 'title' | 'author' | 'added' | 'updated';
order?: 'asc' | 'desc';
page?: number;
@@ -244,6 +266,17 @@ export class ReaderApi {
return this.get<Facets>('/api/v1/library/facets', options);
}
/** Whether this instance has a server-side speech engine, and what it offers. */
async ttsCapabilities(options: RequestOptions = {}): Promise<TtsCapabilities | null> {
try {
return await this.get<TtsCapabilities>('/api/v1/tts/voices', options);
} catch {
// An old server has no such route and a stale one answers 404; both mean
// the same thing to the caller, which is "no HTTP engine here".
return null;
}
}
async continueReading(limit = 20, options: RequestOptions = {}): Promise<ContinueReadingItem[]> {
const result = await this.get<{ items: ContinueReadingItem[] }>(`/api/v1/library/continue?limit=${limit}`, options);
return result.items;
+11
View File
@@ -49,6 +49,17 @@ export interface Book {
source: string;
manualFields: string[];
updatedAt: number;
/**
* When this account first saw the book.
*
* Carried to the client because the shelf sorts its *cached* list while offline
* and must produce the same order the server would; without the key the two
* would disagree exactly when the reader cannot check which is right.
*
* Optional so a client reading a server that predates the field keeps working —
* the sort falls back to `updatedAt`.
*/
addedAt?: number;
}
export interface BookListPage {
+24 -1
View File
@@ -2,7 +2,13 @@ import { ReaderApi, type SessionStore } from './api/client.ts';
import { ApiError } from './api/errors.ts';
import type { Book, Session } from './api/types.ts';
import { createWebPlatform } from './core/web-platform.ts';
import { androidPageHost, createAndroidPlatform, detectAndroidBridge } from './core/android-platform.ts';
import {
androidPageHost,
androidSpeechBridge,
createAndroidPlatform,
detectAndroidBridge,
} from './core/android-platform.ts';
import type { SpeechBridge } from './android-bridge.ts';
import type { NativePageHost } from './ui/native-page.ts';
import type { Platform } from './core/platform.ts';
import { SyncEngine, type SyncStatus } from './core/sync.ts';
@@ -51,6 +57,14 @@ export class App {
* constructed with or without one without either path special-casing.
*/
private pageHost: NativePageHost | null = null;
/**
* The shell's native speech engine, present only on Android shell 3+.
*
* Held alongside the page host because both are the same kind of thing: a
* capability the *shell* has that the shared layer uses when it is there and
* does without when it is not.
*/
private speechBridge: SpeechBridge | null = null;
private screen: string | null = null;
private pendingBook: Book | null = null;
@@ -110,6 +124,7 @@ export class App {
// The native page renderer is resolved here, once, alongside the bridge that
// provides it, so no screen has to ask whether it is running on Android.
this.pageHost = androidPageHost(bridge);
this.speechBridge = androidSpeechBridge(bridge);
return createAndroidPlatform(this.options.defaultServerUrl ?? '', bridge);
}
@@ -146,8 +161,15 @@ export class App {
api: this.api,
offline: this.offline,
platform: this.platform,
// The shelf's own preferences ride in the same per-device settings store as
// the reader's, so the two cannot disagree about where a preference lives.
settings: this.settings,
onOpenBook: (book) => void this.openBook(book),
onSignedOut: () => this.handleSignedOut(),
onSettingsChange: (patch) => {
void this.settingsStore.update(patch);
this.settings = { ...this.settings, ...patch };
},
});
this.shelf = shelf;
this.root.append(shelf.element);
@@ -164,6 +186,7 @@ export class App {
platform: this.platform,
settings: this.settings,
...(this.pageHost ? { pageHost: this.pageHost } : {}),
...(this.speechBridge ? { speechBridge: this.speechBridge } : {}),
onBack: () => this.showShelf(),
onSettingsChange: (patch) => {
void this.settingsStore.update(patch);
+47 -2
View File
@@ -1,4 +1,4 @@
import type { AndroidBridge } from '../android-bridge.ts';
import type { AndroidBridge, SpeechBridge } from '../android-bridge.ts';
import { FetchTransport } from './fetch-transport.ts';
import type { Connectivity, Platform } from './platform.ts';
import { createStores } from '../store/idb.ts';
@@ -100,17 +100,42 @@ export const MIN_SHELL_VERSION = 1;
*/
export const NATIVE_PAGE_SHELL_VERSION = 2;
/**
* First shell version with a native `TextToSpeech` surface.
*
* A feature rather than a capability, like the native page renderer: a shell
* below 3 simply reads with the WebView's own synthesizer. Declared separately so
* the failure mode is understood — an old APK is not refused, it just does not
* appear in the朗读 engine list.
*/
export const NATIVE_SPEECH_SHELL_VERSION = 3;
export function detectAndroidBridge(): AndroidBridge | null {
if (typeof window === 'undefined') return null;
const bridge = window.ReaderAndroid;
if (!bridge || typeof bridge.shellVersion !== 'function') return null;
try {
return bridge.shellVersion() >= MIN_SHELL_VERSION ? bridge : null;
if (bridge.shellVersion() < MIN_SHELL_VERSION) return null;
return promoteSpeechInterface(bridge);
} catch {
return null;
}
}
/**
* The native speech engine, when the shell has one.
*
* Gated on the shell version like the page renderer, and for the same reason: a
* shell below 3 has no `speech` interface, and a client that called into it would
* fail in a way that looks like a reader bug rather than an old APK.
*/
export function androidSpeechBridge(bridge: AndroidBridge): SpeechBridge | null {
if (!shellAtLeast(bridge, NATIVE_SPEECH_SHELL_VERSION)) return null;
const speech = bridge.speech;
if (!speech || typeof speech.speak !== 'function') return null;
return speech;
}
/**
* The Android native renderer, exposed as a page host.
*
@@ -181,6 +206,26 @@ function toBase64(bytes: Uint8Array): string {
return btoa(binary);
}
/**
* Re-parents the flat speech interface into the object shape the client expects.
*
* `addJavascriptInterface` can expose objects, not properties of one, so the
* shell registers a second interface at `window.ReaderAndroidSpeech` (see
* `ReaderBridge.SPEECH_NAME`). Promoting it here keeps the contract in
* `android-bridge.d.ts` — one nested `speech` object — true from the web layer's
* point of view, so nothing above this file knows how the bridge happens to be
* wired on the platform side.
*
* Returns a new object rather than mutating the bridge: the bridge is created by
* the shell and may be shared, and a client that writes into it would be a client
* that can break the shell's own references.
*/
export function promoteSpeechInterface(bridge: AndroidBridge): AndroidBridge {
const raw = window.ReaderAndroidSpeech;
if (!raw) return bridge;
return { ...bridge, speech: raw };
}
function shellAtLeast(bridge: AndroidBridge, version: number): boolean {
try {
return bridge.shellVersion() >= version;
+618
View File
@@ -0,0 +1,618 @@
/**
* The reader's speech layer: three engines behind one shape.
*
* The reader can listen to a book in three ways, and the point of this file is
* that the rest of the client does not need to know which one is in use:
*
* - **System** (`TtsEngine` → `speechSynthesis`). Free, offline, on every
* Android WebView and every Chromium browser. The default.
* - **HTTP** (`HttpTtsEngine` → the reader's own server → whatever `TTS_URL`
* names). For the environments the first one does not reach: a browser with no
* `speechSynthesis`, a desktop with no Chinese voice installed.
* - **Native** (`NativeTtsEngine` → the Android shell's `TextToSpeech`). Same
* idea as the system engine, but reached across the bridge so it uses the
* platform API rather than the WebView's copy of it — which matters because a
* voice installed on the device does not always appear in `getVoices()`.
*
* ## What the abstraction is, and what it is not
*
* It is a queue of sentences and a snapshot of what is being spoken. It is *not*
* a base class: the three engines have genuinely different pause semantics
* (`speechSynthesis` pauses, `<audio>` restarts the sentence, Android stops),
* different failure modes, and different rate behaviour — the `<audio>` engine
* changes speed in place while the other two must re-speak the sentence. A shared
* base would put an `if (engine === …)` in every method; a shared *interface*
* puts them nowhere.
*
* The screen layer talks to `SpeechEngine`, keeps a queue of `SpokenChunk`s (so
* the highlight can be drawn), and hands the engine plain text.
*/
import type { TtsSnapshot, TtsState, TtsVoice } from './tts.ts';
import { TtsEngine } from './tts.ts';
import { HttpTtsEngine } from './tts-http.ts';
import type { SpokenChunk } from './tts-text.ts';
import type { NativeSpeechBridge } from '../android-bridge.ts';
/**
* The one interface the reader screen uses.
*
* `loadQueue` is the engine asking for more sentences, and it is the only place
* chapters enter the picture: the screen layer answers with the next batch, which
* is how "continue into the next chapter" is a preference of the UI rather than a
* branch inside an engine.
*/
export interface SpeechEngine {
readonly kind: 'system' | 'http' | 'native';
readonly active: boolean;
snapshot: TtsSnapshot;
loadQueue: (from: number) => Promise<{ chunks: SpokenChunk[]; startIndex: number }>;
/** Called with the sentence about to be spoken, for the highlight. */
onChunk: (chunk: SpokenChunk, index: number) => void;
onState: (snapshot: TtsSnapshot) => void;
play(from: number): Promise<void>;
pause(): void;
resume(): void;
stop(): void;
next(): void | Promise<void>;
previous(): void | Promise<void>;
jump(index: number): void | Promise<void>;
setRate(rate: number): void;
setPitch(pitch: number): void;
setVolume(volume: number): void;
setVoice(voiceId: string): void;
dispose(): void;
}
export type SpeechEngineKind = 'auto' | 'system' | 'http' | 'native';
export interface SpeechAvailability {
system: boolean;
native: boolean;
http: boolean;
/** The engine `auto` will use, which is the best one this host actually has. */
preferred: Exclude<SpeechEngineKind, 'auto'> | null;
}
/** The order `auto` tries, best first, and the reasoning for each position. */
const PREFERENCE_ORDER: Array<Exclude<SpeechEngineKind, 'auto'>> = [
// Native first on Android: it is the platform API, and it sees voices the
// WebView's `getVoices()` does not (a phone with a Chinese voice installed for
// the OS still returns an empty list to a WebView that has not loaded one).
'native',
// Then the WebView's own synthesizer: online-free, instant, no round trip.
'system',
// HTTP last, because every sentence of it costs a request and it needs a
// server-side engine to be configured at all.
'http',
];
export function speechAvailability(input: {
systemSupported: boolean;
nativeBridge: NativeSpeechBridge | null;
httpConfigured: boolean;
}): SpeechAvailability {
const available: SpeechAvailability = {
system: input.systemSupported,
native: input.nativeBridge !== null,
http: input.httpConfigured,
preferred: null,
};
for (const kind of PREFERENCE_ORDER) {
if (available[kind]) {
available.preferred = kind;
return available;
}
}
return available;
}
/** Human labels, so the settings panel does not inline a switch. */
export const SPEECH_ENGINE_LABELS: Record<Exclude<SpeechEngineKind, 'auto'>, string> = {
native: '系统语音(原生)',
system: '系统语音(浏览器)',
http: 'HTTP 朗读',
};
export interface SpeechFactoryOptions {
kind: Exclude<SpeechEngineKind, 'auto'>;
baseUrl: string;
accessToken: () => string | null;
nativeBridge: NativeSpeechBridge | null;
/** System-engine injection points, forwarded for tests. */
synthesis?: SpeechSynthesis | null;
utteranceFactory?: () => SpeechSynthesisUtterance;
setTimeout?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
clearTimeout?: (handle: ReturnType<typeof setTimeout>) => void;
onError?: (message: string) => void;
}
/**
* Builds one engine.
*
* Returns `null` when the requested kind is unavailable rather than a stub that
* pretends to work: a `null` makes the caller fall back visibly, and a stub makes
* it show a play button that does nothing.
*/
export function createSpeechEngine(options: SpeechFactoryOptions): SpeechEngine | null {
switch (options.kind) {
case 'system':
if (!TtsEngine.isSupported()) return null;
return new TtsEngine({
loadQueue: () => Promise.resolve({ chunks: [], startIndex: 0 }),
onChunk: () => undefined,
onState: () => undefined,
...(options.synthesis !== undefined ? { synthesis: options.synthesis } : {}),
...(options.utteranceFactory ? { utteranceFactory: options.utteranceFactory } : {}),
...(options.setTimeout ? { setTimeout: options.setTimeout } : {}),
...(options.clearTimeout ? { clearTimeout: options.clearTimeout } : {}),
});
case 'native':
if (!options.nativeBridge) return null;
return new NativeTtsEngine(options.nativeBridge, {
...(options.onError ? { onError: options.onError } : {}),
});
case 'http': {
const engine = new HttpTtsEngine({
baseUrl: options.baseUrl,
accessToken: options.accessToken,
...(options.onError ? { onError: options.onError } : {}),
});
return new HttpTtsAdapter(engine);
}
default:
return null;
}
}
/**
* Adapts the HTTP engine to the interface.
*
* The HTTP engine has its own state shape (it has no pitch, no voice list), so a
* thin adapter is what keeps that difference out of the screen layer instead of
* spreading it across every render of the朗读 bar.
*/
class HttpTtsAdapter implements SpeechEngine {
readonly kind = 'http' as const;
loadQueue: SpeechEngine['loadQueue'] = () => Promise.resolve({ chunks: [], startIndex: 0 });
onChunk: SpeechEngine['onChunk'] = () => undefined;
onState: SpeechEngine['onState'] = () => undefined;
private unsubscribe: (() => void) | null = null;
constructor(private readonly engine: HttpTtsEngine) {
engine.setQueueLoader(async (from) => {
const loaded = await this.loadQueue(from);
return { chunks: loaded.chunks.map((chunk) => ({ text: chunk.text })), startIndex: loaded.startIndex };
});
}
get active(): boolean {
return this.engine.active;
}
get snapshot(): TtsSnapshot {
return this.engine.snapshot;
}
async play(from: number): Promise<void> {
// The highlight is drawn from the queue the screen layer built, so the
// adapter reports the sentence it is about to speak for every index it
// advances through — not just the one `play` was called with. It learns the
// queue from `onChunk`'s own consumer via `loadQueue`, which is the only
// place the two sides share the list.
this.unsubscribe?.();
this.lastIndex = -1;
this.unsubscribe = this.engine.onState((snapshot) => {
this.onState(snapshot);
if (snapshot.index === this.lastIndex) return;
this.lastIndex = snapshot.index;
this.emitChunk(snapshot.index);
});
// The trailing emit was removed deliberately: the state listener above already
// fires for the first sentence, and calling both made every chapter start with
// the same highlight painted twice — which is visible as a flicker on a
// low-end phone, and pointless work on any other.
await this.engine.play(from);
}
/** Index of the last chunk reported, so one sentence is highlighted once. */
private lastIndex = -1;
/**
* Maps an engine index back to a sentence.
*
* The engine indexes *fragments*, because a paragraph with no punctuation is
* split before it is sent (the server caps an utterance at 800 characters). The
* fragment's own `start` offset is what maps it back to the sentence the reader
* can see, which is why the split copies the original `node` and adjusts only
* `start`.
*/
private emitChunk(index: number): void {
const chunk = this.queue()[index];
if (chunk) this.onChunk(chunk, index);
}
/**
* The engine's fragment list.
*
* Supplied by the screen layer through `chunkSource`, because the adapter never
* sees the queue otherwise — it hands plain text to the HTTP engine.
*/
chunkSource: () => SpokenChunk[] = () => [];
private queue(): SpokenChunk[] {
return this.chunkSource();
}
pause(): void {
this.engine.pause();
}
resume(): void {
this.engine.resume();
}
stop(): void {
this.engine.stop();
}
next(): void {
this.engine.next();
}
previous(): void {
this.engine.previous();
}
jump(index: number): void {
this.engine.jump(index);
}
setRate(rate: number): void {
this.engine.setRate(rate);
}
setPitch(): void {
// Synthesised audio has no pitch control that is not a playback rate change,
// and a shimmed one is worse than none. The settings row is hidden for this
// engine instead of silently doing nothing here.
}
setVolume(volume: number): void {
this.engine.setVolume(volume);
}
setVoice(voiceId: string): void {
this.engine.setVoice(voiceId);
}
dispose(): void {
this.unsubscribe?.();
this.engine.dispose();
}
}
/**
* Android's `TextToSpeech`, across the bridge.
*
* ## Why this exists when the WebView already has a synthesizer
*
* It is not the same synthesizer. A WebView's `speechSynthesis` is backed by the
* engine the *WebView* was built against, and its `getVoices()` list is routinely
* empty or English-only on a phone whose OS-level TTS has a Chinese voice
* installed and working. That is the single most common "朗读没有中文声音" report,
* and the only fix is to ask the platform directly.
*
* ## Why the queue is driven from here rather than from Kotlin
*
* The native side reports `onStart`/`onDone` per utterance, and that is all it
* is asked to do. The cursor, the chapter boundary and the settings stay in
* JavaScript, which is what keeps the highlight, the progress bar and "下一句"
* identical whichever engine is speaking. Kotlin owning a queue would be a second
* implementation of a rule that already exists.
*/
class NativeTtsEngine implements SpeechEngine {
readonly kind = 'native' as const;
loadQueue: SpeechEngine['loadQueue'] = () => Promise.resolve({ chunks: [], startIndex: 0 });
onChunk: SpeechEngine['onChunk'] = () => undefined;
onState: SpeechEngine['onState'] = () => undefined;
private queue: SpokenChunk[] = [];
private cursor = -1;
private state: TtsState = 'idle';
private error = '';
private rate = 1;
private pitch = 1;
private volume = 1;
private voiceId = '';
private voices: TtsVoice[] = [];
private generation = 0;
private speaking = false;
private watchdog: ReturnType<typeof setTimeout> | null = null;
/** Id of the utterance in flight, matched against the shell's events. */
private utteranceId = '';
constructor(
private readonly bridge: NativeSpeechBridge,
private readonly options: { onError?: (message: string) => void },
) {
// The shell reports sentence completion; the callbacks are installed once and
// dispatch through the generation counter so a cancelled utterance that
// arrives late cannot advance the cursor.
bridge.onSpeechEvent((event) => this.onNativeEvent(event));
bridge.onVoices?.((voices) => {
this.voices = voices.map((voice) => ({
id: voice.id,
name: voice.name,
lang: voice.lang,
default: voice.default === true,
}));
this.emit();
});
try {
bridge.init?.();
} catch {
// An old shell without `init` still speaks with the default engine.
}
}
get active(): boolean {
return this.state === 'playing' || this.state === 'paused';
}
get snapshot(): TtsSnapshot {
return {
state: this.state,
chunk: this.queue[this.cursor]?.text ?? '',
index: this.cursor,
total: this.queue.length,
rate: this.rate,
pitch: this.pitch,
volume: this.volume,
voiceId: this.voiceId,
voices: [...this.voices],
error: this.error,
};
}
async play(from = 0): Promise<void> {
if (this.state === 'paused') {
this.resume();
return;
}
this.generation += 1;
this.bridge.stop();
const loaded = await this.loadQueue(from);
if (loaded.chunks.length === 0) {
this.state = 'idle';
this.error = '这一章没有可朗读的文字';
this.emit();
return;
}
this.queue = loaded.chunks as SpokenChunk[];
this.cursor = clamp(loaded.startIndex, 0, this.queue.length - 1) - 1;
this.error = '';
this.state = 'playing';
this.speakNext();
}
pause(): void {
if (this.state !== 'playing') return;
this.state = 'paused';
this.bridge.pause();
this.emit();
}
resume(): void {
if (this.state !== 'paused') return;
this.state = 'playing';
// `TextToSpeech` has no resume: a paused utterance has to be re-queued, and
// re-queuing the *current* sentence is the same trade the other engines make.
this.cursor -= 1;
this.speakNext();
}
stop(): void {
this.generation += 1;
this.clearWatchdog();
this.speaking = false;
this.bridge.stop();
this.queue = [];
this.cursor = -1;
this.state = 'idle';
this.error = '';
this.utteranceId = '';
this.emit();
}
next(): void {
if (this.queue.length === 0) return;
this.jump(this.cursor + 1);
}
previous(): void {
if (this.queue.length === 0) return;
const index = this.state === 'playing' ? this.cursor : this.cursor - 1;
this.jump(Math.max(0, index));
}
jump(index: number): void {
if (this.queue.length === 0) return;
this.generation += 1;
this.clearWatchdog();
this.speaking = false;
this.bridge.stop();
this.cursor = clamp(index, 0, this.queue.length - 1) - 1;
this.state = 'playing';
this.speakNext();
}
setRate(rate: number): void {
this.rate = clamp(rate, 0.5, 3);
this.bridge.setRate(this.rate);
this.restartCurrentSentence();
}
setPitch(pitch: number): void {
this.pitch = clamp(pitch, 0.5, 2);
this.bridge.setPitch(this.pitch);
this.restartCurrentSentence();
}
setVolume(volume: number): void {
this.volume = clamp(volume, 0, 1);
this.bridge.setVolume(this.volume);
this.emit();
}
setVoice(voiceId: string): void {
this.voiceId = voiceId;
this.bridge.setVoice(voiceId);
this.restartCurrentSentence();
}
dispose(): void {
this.generation += 1;
this.clearWatchdog();
this.bridge.stop();
// `shutdown` rather than `stop`: the screen is going away, so the bound
// service should be released rather than left ready for a sentence that will
// never come. The shell also does this on activity destroy, but doing it here
// means closing a book releases the engine immediately instead of holding it
// for the lifetime of the app.
try {
this.bridge.shutdown();
} catch {
// An older shell without `shutdown` still stops, which is the part that
// matters for silence.
}
this.queue = [];
this.cursor = -1;
this.state = 'idle';
}
// ---- internals ----
private speakNext(): void {
if (this.state !== 'playing') return;
const nextIndex = this.cursor + 1;
const chunk = this.queue[nextIndex];
if (!chunk) {
void this.finish();
return;
}
this.cursor = nextIndex;
this.onChunk(chunk, nextIndex);
this.emit();
this.bridge.setRate(this.rate);
this.bridge.setPitch(this.pitch);
this.bridge.setVolume(this.volume);
const generation = this.generation;
this.speaking = true;
// The id is minted here and echoed by the shell, which is how a `done` that
// arrives after the reader pressed "next" is recognised as stale.
this.utteranceId = `u${generation}-${nextIndex}`;
try {
this.bridge.speak(chunk.text, this.utteranceId);
} catch (err) {
this.speaking = false;
this.fail(err instanceof Error ? err.message : '原生朗读失败');
return;
}
// The watchdog mirrors the system engine's: an Android TTS engine that has
// gone silent without an `onDone` is a real, observed behaviour after a long
// screen-off period, and without this the bar would sit on "playing" forever.
this.clearWatchdog();
const budget = Math.max(8000, (chunk.text.length / (this.rate * 4)) * 1000 * 3);
this.watchdog = setTimeout(() => {
this.watchdog = null;
if (generation !== this.generation || !this.speaking) return;
this.speaking = false;
if (this.state === 'playing') this.speakNext();
}, budget);
}
private onNativeEvent(event: { type: string; id?: string; message?: string }): void {
if (!this.active) return;
// A late event for something the reader has moved past is not an error and
// must not touch the cursor: it would skip a sentence, which is exactly the
// bug the id exists to prevent.
if (event.id !== undefined && event.id !== '' && event.id !== this.utteranceId) return;
switch (event.type) {
case 'done':
if (!this.speaking) return;
this.speaking = false;
this.clearWatchdog();
this.speakNext();
break;
case 'start':
// Nothing to do: the cursor moved when the utterance was queued, and
// acting here would double-advance.
break;
case 'error':
this.speaking = false;
this.clearWatchdog();
this.fail(event.message || '原生朗读失败');
break;
default:
break;
}
}
private async finish(): Promise<void> {
const generation = ++this.generation;
this.state = 'idle';
this.emit();
try {
const loaded = await this.loadQueue(this.queue.length);
if (generation !== this.generation || loaded.chunks.length === 0) return;
this.queue = loaded.chunks as SpokenChunk[];
this.cursor = clamp(loaded.startIndex, 0, this.queue.length - 1) - 1;
this.state = 'playing';
this.speakNext();
} catch {
// The status bar owns the connectivity explanation.
}
}
private restartCurrentSentence(): void {
this.emit();
if (this.state !== 'playing') return;
const index = this.cursor;
if (index < 0) return;
this.generation += 1;
this.clearWatchdog();
this.speaking = false;
this.utteranceId = '';
this.bridge.stop();
this.cursor = index - 1;
setTimeout(() => {
if (this.state === 'playing') this.speakNext();
}, 0);
}
private fail(message: string): void {
this.error = message;
this.state = 'idle';
this.bridge.stop();
this.emit();
this.options.onError?.(message);
}
private clearWatchdog(): void {
if (this.watchdog === null) return;
clearTimeout(this.watchdog);
this.watchdog = null;
}
private emit(): void {
this.onState(this.snapshot);
}
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, value));
}
+438
View File
@@ -0,0 +1,438 @@
/**
* Read-aloud, over HTTP.
*
* The second of the reader's two engines, and the one that exists because the
* first one cannot always be there: `speechSynthesis` is missing from Firefox on
* Linux and from plenty of Android builds, and a device with no Chinese voice
* installed will read a novel in a language the reader does not speak. Neither is
* a bug the reader can fix, so the reader offers a way out.
*
* ## Where the audio comes from
*
* The *server* holds the engine URL, not the client. A self-hosted reader's phone
* may be on a train, and a third-party TTS key must not be shipped in a bundle
* that anyone can open. So the client asks its own server (`GET /api/v1/tts`),
* the server asks whatever `TTS_URL` names, and the bytes come back.
*
* ## Why an `<audio>` element and not Web Audio
*
* Three reasons, and the first is the one that decides it:
*
* 1. **The access token rides in the query string on nothing else.** An
* `<audio src>` is a browser-managed request: it cannot carry an
* `Authorization` header, and it will not accept a `fetch` + Blob URL without
* losing the buffering that makes playback start before the sentence has
* finished downloading. That is exactly why the server accepts a query token
* on this one route (see `server/src/http/auth.ts`), and only on `GET`.
* 2. **The platform's own decode path.** Media decoding happens off the main
* thread and out of the JS heap; a 60-minute novel is a lot of audio, and
* Web Audio would keep it all in the renderer process.
* 3. **The events come for free.** `ended`, `error` and `waiting` are what make
* a queue advance without a timer, and `playbackRate` is a real rate change
* rather than a re-synthesis.
*
* ## The one thing this engine cannot do, and says so
*
* A remote sentence cannot be highlighted word by word: there is no `boundary`
* event, and the audio's timeline has no mapping back into the DOM. Sentence
* granularity is what the highlight gets — which the system engine also gives,
* for the same reason it does not rely on `boundary` (see `tts.ts`). The two
* engines therefore have the same visible behaviour and differ only in where the
* sound comes from, which is the point.
*/
import type { TtsSnapshot, TtsState, TtsVoice } from './tts.ts';
/** What the reader's server says it can do. */
export interface HttpTtsCapabilities {
http: boolean;
formats: string[];
maxLength: number;
voices: TtsVoice[];
}
export interface HttpTtsOptions {
/** Absolute URL of the server, e.g. `http://192.168.1.10:8080`. */
baseUrl: string;
/** Bearer token; sent as a query parameter, see the note above. */
accessToken: () => string | null;
/** Injectable for tests: the element factory, so no audio device is needed. */
createAudio?: () => HTMLAudioElement;
/** Injectable for tests. */
setTimeout?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
clearTimeout?: (handle: ReturnType<typeof setTimeout>) => void;
onError?: (message: string) => void;
}
/**
* How long to wait for a sentence's audio before giving up.
*
* A local Piper wrapper answers in a second; a remote neural service on a bad
* mobile connection can take ten. The bound exists because a request that never
* answers must not wedge the queue — a reader listening to a book while walking
* would otherwise find that the "next sentence" button does nothing at all.
*/
const REQUEST_TIMEOUT_MS = 20_000;
/** Longest single utterance this engine will ask for. Mirrors the server's cap. */
export const HTTP_TTS_MAX_LENGTH = 800;
/**
* The HTTP engine.
*
* Deliberately shaped like `TtsEngine` rather than sharing a base class with it:
* the two have different failure modes (a missing voice versus an unreachable
* server), different pause semantics, and the system engine's queue bookkeeping
* is driven by an utterance callback that has no counterpart here. A shared base
* would be an `if (engine === ...)` in every method.
*/
export class HttpTtsEngine {
private readonly options: HttpTtsOptions;
private audio: HTMLAudioElement | null = null;
private state: TtsState = 'idle';
private error = '';
private queue: string[] = [];
private cursor = -1;
private rate = 1;
private voiceId = '';
/** Generation counter: bumped on stop, so a late response is ignored. */
private generation = 0;
private timeout: ReturnType<typeof setTimeout> | null = null;
private readonly listeners = new Set<(snapshot: TtsSnapshot) => void>();
private loadQueue: ((from: number) => Promise<{ chunks: Array<{ text: string }>; startIndex: number } | null>) | null = null;
constructor(options: HttpTtsOptions) {
this.options = options;
}
/**
* Capability probe.
*
* Answers `null` when the server has no engine configured or cannot be reached,
* so the caller falls back to the system engine rather than showing a picker
* that does not work.
*/
async probe(): Promise<HttpTtsCapabilities | null> {
const url = this.buildUrl('/api/v1/tts/voices', {});
if (!url) return null;
try {
const response = await fetch(url, { headers: this.headers(), mode: 'cors', credentials: 'omit' });
if (!response.ok) return null;
const body = (await response.json()) as Partial<HttpTtsCapabilities>;
if (!body.http) return null;
return {
http: true,
formats: body.formats ?? ['audio/mpeg'],
maxLength: body.maxLength ?? HTTP_TTS_MAX_LENGTH,
voices: body.voices ?? [],
};
} catch {
// Offline is the common case here, and it is not worth an error message:
// the system engine is still available and the reader may not even care.
return null;
}
}
get snapshot(): TtsSnapshot {
return {
state: this.state,
chunk: this.queue[this.cursor] ?? '',
index: this.cursor,
total: this.queue.length,
rate: this.rate,
// Synthesised audio has no pitch control that does not go through
// `playbackRate`, and a shimmed pitch is worse than no pitch control.
pitch: 1,
volume: this.audio?.volume ?? 1,
voiceId: this.voiceId,
voices: [],
error: this.error,
};
}
get active(): boolean {
return this.state === 'playing' || this.state === 'paused';
}
onState(listener: (snapshot: TtsSnapshot) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* Bind the sentence source.
*
* Kept as a setter rather than a constructor argument so the engine can be
* built before a book is open: the settings panel needs to probe the server's
* capabilities without a document in hand.
*/
setQueueLoader(loader: (from: number) => Promise<{ chunks: Array<{ text: string }>; startIndex: number }>): void {
this.loadQueue = loader;
}
async play(from = 0): Promise<void> {
if (!this.loadQueue) return;
if (this.state === 'paused') {
this.resume();
return;
}
const loaded = await this.loadQueue(from);
if (!loaded || loaded.chunks.length === 0) {
this.state = 'idle';
this.error = '这一章没有可朗读的文字';
this.emit();
return;
}
this.stopAudio();
this.generation += 1;
this.queue = loaded.chunks.map((chunk) => chunk.text);
this.cursor = clamp(loaded.startIndex, 0, this.queue.length - 1) - 1;
this.error = '';
this.state = 'playing';
this.speakNext();
}
pause(): void {
if (this.state !== 'playing') return;
this.state = 'paused';
this.audio?.pause();
this.emit();
}
resume(): void {
if (this.state !== 'paused') return;
this.state = 'playing';
// The sentence is replayed from the start rather than resumed mid-word. This
// is the honest behaviour for a remote engine: the audio element was
// released when paused (see `pause`), so there is no position to resume to,
// and a reader who paused does not mind hearing the sentence's first word
// again — but does mind hearing the previous one.
this.speakNext();
}
stop(): void {
this.generation += 1;
this.stopAudio();
this.queue = [];
this.cursor = -1;
this.state = 'idle';
this.error = '';
this.emit();
}
/** Jump to a sentence by index, continuing playback. */
jump(index: number): void {
if (this.queue.length === 0) return;
this.generation += 1;
this.stopAudio();
this.cursor = clamp(index, 0, this.queue.length - 1) - 1;
this.state = 'playing';
this.speakNext();
}
next(): void {
if (this.queue.length === 0) return;
this.jump(this.cursor + 1);
}
previous(): void {
if (this.queue.length === 0) return;
// Same rule as the system engine: a press during playback restarts the
// current sentence, otherwise it steps back one. The two engines agreeing on
// this is what makes switching between them invisible in use.
const index = this.state === 'playing' ? this.cursor : this.cursor - 1;
this.jump(Math.max(0, index));
}
setRate(rate: number): void {
this.rate = clamp(rate, 0.5, 3);
// A rate change is a property of the audio element, not a re-synthesis:
// `playbackRate` is applied to the audio already playing, so this is the one
// place where the HTTP engine is *better* than the system one, which has to
// restart the sentence.
if (this.audio) this.audio.playbackRate = this.rate;
this.emit();
}
setVolume(volume: number): void {
const value = clamp(volume, 0, 1);
if (this.audio) this.audio.volume = value;
this.emit();
}
setVoice(voiceId: string): void {
this.voiceId = voiceId;
this.emit();
}
dispose(): void {
this.generation += 1;
this.stopAudio();
this.queue = [];
this.listeners.clear();
}
// ---- internals ----
/**
* Fetches and plays the sentence after the cursor, or finishes.
*
* All terminal paths funnel here — the `ended` event, the timeout, the
* queue-exhausted branch — for the same reason the system engine does: two of
* them advancing the cursor at once is the bug that makes a reader repeat a
* sentence forever.
*/
private speakNext(): void {
if (this.state !== 'playing') return;
const nextIndex = this.cursor + 1;
const text = this.queue[nextIndex];
if (text === undefined) {
void this.finish();
return;
}
this.cursor = nextIndex;
this.emit();
const generation = this.generation;
const url = this.buildUrl('/api/v1/tts', {
text,
...(this.voiceId ? { voice: this.voiceId } : {}),
speed: String(this.rate),
});
if (!url) {
this.fail('HTTP 朗读未配置');
return;
}
const audio = this.createAudio();
audio.preload = 'auto';
audio.volume = clamp(1, 0, 1);
this.audio = audio;
const advance = (): void => {
if (generation !== this.generation) return;
this.clearTimeout();
this.speakNext();
};
audio.addEventListener('ended', advance, { once: true });
audio.addEventListener('error', () => {
if (generation !== this.generation) return;
this.clearTimeout();
this.fail('朗读音频加载失败');
}, { once: true });
audio.src = url;
audio.playbackRate = this.rate;
void audio.play().catch(() => {
// Autoplay policies reject play() when it is not the direct result of a
// gesture. The reader did press play, so this is almost always a
// transient decode/permission failure rather than a policy one; reporting
// it is better than a bar that says "playing" while nothing is heard.
if (generation !== this.generation) return;
this.fail('浏览器阻止了朗读播放,请再按一次播放');
});
// The watchdog is what makes a stalled request visible instead of silence.
this.clearTimeout();
const schedule = this.options.setTimeout ?? setTimeout;
this.timeout = schedule(() => {
this.timeout = null;
if (generation !== this.generation) return;
if (this.state === 'playing') this.speakNext();
}, REQUEST_TIMEOUT_MS);
}
/**
* The queue ran out. Asks the screen layer for the next chapter, exactly as the
* system engine does, so "章节播完继续" is one implementation for both engines.
*/
private async finish(): Promise<void> {
const generation = ++this.generation;
this.state = 'idle';
this.emit();
if (!this.loadQueue) return;
try {
const loaded = await this.loadQueue(this.queue.length);
if (generation !== this.generation || !loaded || loaded.chunks.length === 0) return;
this.queue = loaded.chunks.map((chunk) => chunk.text);
this.cursor = clamp(loaded.startIndex, 0, this.queue.length - 1) - 1;
this.state = 'playing';
this.speakNext();
} catch {
// A chapter that will not load stops the reading; the status bar already
// reports the connectivity problem that caused it.
}
}
private fail(message: string): void {
this.error = message;
this.state = 'idle';
this.stopAudio();
this.emit();
this.options.onError?.(message);
}
private createAudio(): HTMLAudioElement {
if (this.options.createAudio) return this.options.createAudio();
return new Audio();
}
private stopAudio(): void {
this.clearTimeout();
const audio = this.audio;
this.audio = null;
if (!audio) return;
// Releasing the element matters more than stopping it: an `<audio>` left with
// a `src` keeps a network buffer alive, and a chapter of sentences would leak
// one per sentence.
try {
audio.pause();
audio.removeAttribute('src');
audio.load();
} catch {
// A detached element in a test double; nothing to release.
}
}
private clearTimeout(): void {
if (this.timeout === null) return;
(this.options.clearTimeout ?? clearTimeout)(this.timeout);
this.timeout = null;
}
/**
* Builds a URL against the server.
*
* The token is a query parameter and that is deliberate — see the file header.
* It is why this engine is only ever pointed at the reader's own server: a
* URL with a bearer token in it must not go to a third party, in logs or in a
* `Referer` header.
*/
private buildUrl(path: string, params: Record<string, string>): string | null {
const base = this.options.baseUrl.replace(/\/+$/, '');
if (!base) return null;
const query = new URLSearchParams(params);
const token = this.options.accessToken();
if (token) query.set('access_token', token);
const suffix = query.size > 0 ? `?${query.toString()}` : '';
return `${base}${path}${suffix}`;
}
private headers(): Record<string, string> {
const token = this.options.accessToken();
return {
accept: 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
};
}
private emit(): void {
const snapshot = this.snapshot;
for (const listener of this.listeners) listener(snapshot);
}
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, value));
}
+49 -6
View File
@@ -34,6 +34,33 @@
import type { SpokenChunk } from './tts-text.ts';
/**
* The structural contract `speech.ts` uses to treat the three engines alike.
*
* Declared here rather than imported from `speech.ts` so this file stays free of
* a circular import: the engines are the leaves, the facade is the branch.
*/
export interface SpeechEngineShape {
readonly kind: 'system' | 'http' | 'native';
readonly active: boolean;
snapshot: TtsSnapshot;
loadQueue: (from: number) => Promise<{ chunks: SpokenChunk[]; startIndex: number }>;
onChunk: (chunk: SpokenChunk, index: number) => void;
onState: (snapshot: TtsSnapshot) => void;
play(from: number): Promise<void>;
pause(): void;
resume(): void;
stop(): void;
next(): void | Promise<void>;
previous(): void | Promise<void>;
jump(index: number): void | Promise<void>;
setRate(rate: number): void;
setPitch(pitch: number): void;
setVolume(volume: number): void;
setVoice(voiceId: string): void;
dispose(): void;
}
export type TtsState = 'idle' | 'playing' | 'paused' | 'unsupported';
export interface TtsVoice {
@@ -85,7 +112,21 @@ const DEFAULT_RATE = 1;
const DEFAULT_PITCH = 1;
const DEFAULT_VOLUME = 1;
export class TtsEngine {
export class TtsEngine implements SpeechEngineShape {
/**
* The three callbacks, as public mutable fields.
*
* They are assigned from the options rather than read off them, so the engine
* satisfies `SpeechEngine` structurally. That is what lets the screen layer
* hold a `SpeechEngine` and swap implementations without the three engines
* sharing a base class — which they must not, because their pause and rate
* semantics genuinely differ (see `speech.ts`).
*/
loadQueue: (from: number) => Promise<{ chunks: SpokenChunk[]; startIndex: number }>;
onChunk: (chunk: SpokenChunk, index: number) => void;
onState: (snapshot: TtsSnapshot) => void;
readonly kind = 'system' as const;
private readonly options: TtsOptions;
private readonly voices: TtsVoice[] = [];
private queue: SpokenChunk[] = [];
@@ -102,10 +143,12 @@ export class TtsEngine {
private speaking = false;
/** Watchdog: some Android builds never fire `onend` for a cancelled utterance. */
private watchdog: ReturnType<typeof setTimeout> | null = null;
private readonly noop = (): void => undefined;
constructor(options: TtsOptions) {
this.options = options;
this.loadQueue = options.loadQueue;
this.onChunk = options.onChunk;
this.onState = options.onState;
this.rate = DEFAULT_RATE;
this.loadVoices();
// Chrome fires this after the voice list is populated, which on a cold start
@@ -158,7 +201,7 @@ export class TtsEngine {
}
this.generation += 1;
synthesis.cancel();
const loaded = await this.options.loadQueue(from);
const loaded = await this.loadQueue(from);
if (loaded.chunks.length === 0) {
this.status = 'idle';
this.error = '这一章没有可朗读的文字';
@@ -282,7 +325,7 @@ export class TtsEngine {
}
this.cursor = nextIndex;
this.options.onChunk(chunk, nextIndex);
this.onChunk(chunk, nextIndex);
this.emit();
const utterance = this.options.utteranceFactory
@@ -356,7 +399,7 @@ export class TtsEngine {
// The queue is intentionally left in place so a rewind works, but the
// controller is told playback ran out so it can continue into the next
// chapter instead of stopping at every chapter boundary.
void this.options
void this
.loadQueue(this.queue.length)
.then((loaded) => {
if (generation !== this.generation || !loaded.chunks.length) return;
@@ -427,7 +470,7 @@ export class TtsEngine {
}
private emit(): void {
(this.options.onState ?? this.noop)(this.snapshot);
this.onState(this.snapshot);
}
}
+13
View File
@@ -158,6 +158,19 @@ export class OfflineStore {
return this.snapshot.progress[bookId];
}
/**
* Synchronous read of one book's progress.
*
* The async form above is what the sync engine uses; this is for the shelf,
* which draws a progress bar on every card and cannot await one per card — an
* `await` inside a render loop is how a 200-book shelf takes a second to appear.
* The snapshot is already in memory by then, so the promise would have resolved
* immediately anyway.
*/
progressFor(bookId: string): Progress | undefined {
return this.snapshot.progress[bookId];
}
async upsertNotes(notes: Note[]): Promise<void> {
for (const note of notes) {
if (note.deleted) {
+38
View File
@@ -12,6 +12,11 @@ import type { ViewSettings } from '../ui/reader-view.ts';
const KEY = 'reader.settings.v1';
const SERVER_KEY = 'reader.server.v1';
/** How densely the shelf lays its covers out. */
export type ShelfDensity = 'compact' | 'cozy' | 'comfortable';
/** The shelf's default ordering, per device like the rest of the settings. */
export type ShelfSort = 'updated' | 'added' | 'title' | 'author';
export interface AppSettings extends ViewSettings {
/** UTF-8 by default; a reader can force another for an odd TXT file. */
txtEncoding: string;
@@ -24,6 +29,34 @@ export interface AppSettings extends ViewSettings {
ttsVoice: string;
/** Keep reading into the next chapter when the current one runs out. */
ttsAutoAdvance: boolean;
/**
* Which engine speaks.
*
* `auto` rather than a concrete engine, because the right answer depends on the
* host and the reader has no way to know: the native engine does not exist in a
* browser, the HTTP engine does not exist on a server with no `TTS_URL`, and a
* phone that had one engine yesterday may have another today. `auto` resolves
* per session against what is actually there (see `speechAvailability`), and
* pinning a specific engine is offered for the reader who wants it — usually to
* escape a bad voice on the system engine.
*/
ttsEngine: 'auto' | 'system' | 'http' | 'native';
// ---- shelf ----
/**
* Covers per row, i.e. how much of the title is readable.
*
* A per-device setting for the same reason the font size is: a phone with a
* 6-inch screen and a desktop monitor want different numbers of covers, and a
* synced value would be wrong on one of them.
*/
shelfDensity: ShelfDensity;
/** What the shelf sorts by when the reader has not picked this session. */
shelfSort: ShelfSort;
/** Show the author under each cover. Off makes the grid much quieter. */
shelfShowAuthor: boolean;
/** Show the progress bar under a cover that has been started. */
shelfShowProgress: boolean;
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
@@ -46,6 +79,11 @@ export const DEFAULT_APP_SETTINGS: AppSettings = {
ttsVolume: 1,
ttsVoice: '',
ttsAutoAdvance: true,
ttsEngine: 'auto',
shelfDensity: 'cozy',
shelfSort: 'updated',
shelfShowAuthor: true,
shelfShowProgress: true,
};
export class SettingsStore {
+210
View File
@@ -750,6 +750,216 @@ book-content ::selection {
text-overflow: ellipsis;
}
/* ---- shelf: search, toolbar, density ---- */
.shelf-search {
position: relative;
align-items: center;
}
.shelf-search input {
flex: 1 1 auto;
min-width: 0;
}
/*
* The clear button sits *inside* the field rather than beside it.
*
* A phone's `type=search` renders its own clear affordance in some browsers and
* none in others, and the one it renders is 12px of low-contrast glyph at the
* edge of a control the reader has to hit with a thumb. Owning the button is the
* only way to get the same thing in both cases.
*/
.search-clear {
position: absolute;
inset-inline-end: 0.35rem;
top: 50%;
transform: translateY(-50%);
width: 1.9rem;
height: 1.9rem;
border: 0;
border-radius: 50%;
background: color-mix(in srgb, var(--reader-ink) 12%, transparent);
color: var(--reader-ink);
font-size: 0.8rem;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
}
.search-clear[hidden] {
display: none;
}
.shelf-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.25rem 0 0.75rem;
}
.shelf-count {
font-size: 0.75rem;
white-space: nowrap;
}
.shelf-sort {
display: flex;
align-items: center;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
flex: 1 1 auto;
min-width: 0;
}
.shelf-sort::-webkit-scrollbar {
display: none;
}
.shelf-sort-label {
font-size: 0.72rem;
white-space: nowrap;
}
.shelf-settings-button {
width: 2.2rem;
height: 2.2rem;
font-size: 1rem;
flex: 0 0 auto;
}
.shelf-refresh {
font-size: 0.78rem;
color: var(--reader-muted);
text-align: center;
padding: 0.35rem 0;
}
.shelf-refresh[hidden] {
display: none;
}
/*
* Density is a custom property, not three `grid-template-columns` rules on three
* classes: the grid then has one declaration and the reader's choice is one value.
* The numbers are chosen so a phone shows three covers at "紧凑" and two at
* "宽松", and a desktop scales up from there without a media query.
*/
.book-grid {
--shelf-cover-min: 7rem;
}
.book-grid[data-density='compact'] {
--shelf-cover-min: 5.25rem;
}
.book-grid[data-density='comfortable'] {
--shelf-cover-min: 9.5rem;
}
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(var(--shelf-cover-min), 1fr));
}
.book-grid[data-show-author='false'] .book-card .author {
display: none;
}
.book-grid[data-show-progress='false'] .cover-progress {
display: none;
}
/*
* Progress on the cover.
*
* Absolute rather than a row under the title: the point of the bar is to be
* readable at a glance while skimming a shelf, and a 3px line under 11px of
* author text is not glancing distance from a cover. It sits at the bottom edge
* of the cover art, where the eye already is.
*/
.book-card .cover-progress {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
height: 4px;
background: rgba(0, 0, 0, 0.35);
}
.book-card .cover-progress span {
display: block;
height: 100%;
background: var(--reader-accent);
}
/* ---- shelf settings sheet ---- */
.shelf-settings .switch-field {
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.shelf-settings .switch-text {
flex: 1 1 auto;
min-width: 0;
font-size: 0.9rem;
}
.shelf-settings .switch-text .muted {
font-size: 0.72rem;
margin-top: 0.15rem;
}
/*
* The switch.
*
* A 2.6rem pill with the knob drawn by `::after`. `aria-pressed` drives both the
* colour and the knob position, so there is exactly one source of truth for "is
* this on" — a separate class would be a second one, and the two would eventually
* disagree.
*/
.shelf-settings .switch {
flex: 0 0 auto;
position: relative;
width: 2.7rem;
height: 1.6rem;
border-radius: 1rem;
border: 0;
background: var(--reader-line);
cursor: pointer;
transition: background 0.15s ease;
}
.shelf-settings .switch::after {
content: '';
position: absolute;
top: 0.2rem;
inset-inline-start: 0.2rem;
width: 1.2rem;
height: 1.2rem;
border-radius: 50%;
background: var(--reader-surface);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.25);
transition: inset-inline-start 0.15s ease;
}
.shelf-settings .switch[aria-pressed='true'] {
background: var(--reader-accent);
}
.shelf-settings .switch[aria-pressed='true']::after {
inset-inline-start: 1.3rem;
}
@media (prefers-reduced-motion: reduce) {
.shelf-settings .switch,
.shelf-settings .switch::after {
transition: none;
}
}
/* ---- panels ---- */
.panel {
+308 -47
View File
@@ -15,6 +15,14 @@ import type { TocEntry } from '../api/types.ts';
import type { NativePageHost } from './native-page.ts';
import { TtsEngine, type TtsSnapshot } from '../render/tts.ts';
import type { SpokenChunk } from '../render/tts-text.ts';
import {
createSpeechEngine,
speechAvailability,
SPEECH_ENGINE_LABELS,
type SpeechEngine,
type SpeechEngineKind,
} from '../render/speech.ts';
import type { NativeSpeechBridge } from '../android-bridge.ts';
/**
* Chapters per window, matching the server's own constant.
@@ -41,6 +49,15 @@ export interface ReaderScreenOptions {
* behaviour the H5 build has always had.
*/
pageHost?: NativePageHost;
/**
* The Android shell's own speech engine, when it has one.
*
* Optional for the same reason as `pageHost`: a browser has none, and a shell
* older than version 3 has none either. `undefined` means "the WebView's own
* synthesizer is the best available engine", which is a perfectly good
* outcome — it is what every browser build does today.
*/
speechBridge?: NativeSpeechBridge;
onBack(): void;
onSettingsChange(patch: Partial<AppSettings>): void;
onSignedOut(): void;
@@ -79,11 +96,32 @@ export class ReaderScreen {
private readonly ttsRangeInput: HTMLInputElement;
private readonly ttsLabel: HTMLSpanElement;
private readonly ttsPlayButton: HTMLButtonElement;
private tts: TtsEngine | null = null;
/**
* The engine currently speaking.
*
* Held as the `SpeechEngine` interface rather than as `TtsEngine`, because
* there are three implementations and which one is in use depends on the host
* (see `speechAvailability`). Everything below this point — the queue, the
* highlight, the bar — is identical for all three.
*/
private tts: SpeechEngine | null = null;
/** The sentence at the reader's own position, for "read from here". */
private pendingSpeechAnchor: SpokenChunk | null = null;
/** Chapter the current speech queue was built from. */
private spokenSectionIndex = -1;
/**
* The engine's fragment list, which is what an engine index refers to.
*
* It differs from the view's own sentence list for the HTTP engine: a paragraph
* with no punctuation is split before it is sent, and the engine counts
* fragments while the reader counts sentences. Both lists are kept so the
* highlight can be drawn from one and the progress bar counted in the other.
*/
private lastSpeechQueue: SpokenChunk[] = [];
/** What the server said it can synthesise. */
private httpTtsAvailable = false;
/** Probed once per screen; a server's capabilities do not change mid-book. */
private httpProbed = false;
private view: ReaderView | null = null;
private doc: BookDoc | null = null;
@@ -640,16 +678,30 @@ export class ReaderScreen {
// Rebuild the voice list on every open: a Bluetooth headset paired while
// the book was open adds a voice, and Chrome only reports it asynchronously.
this.refreshVoiceOptions();
// The server's capability answer is cached after the first probe, so this is
// a no-op on every subsequent open.
if (!this.httpProbed) {
this.httpProbed = true;
void this.probeHttpTts();
}
}
}
/** Re-reads the engine's voice list into the settings panel's select. */
/**
* Re-reads the engine's voice list into the settings panel's select.
*
* Done on every open rather than once, because three things can change under a
* reader: a Bluetooth headset that adds a voice, a desktop Chrome that only
* populates `getVoices()` after `voiceschanged`, and a native engine whose
* voices arrive from a service that was not bound yet. The `signature` check is
* what keeps that from rebuilding the `<select>` on every keystroke of the
* panel.
*/
private refreshVoiceOptions(): void {
if (!TtsEngine.isSupported()) return;
const engine = this.ensureTts();
const select = this.settingsPanel.querySelector<HTMLSelectElement>('select[data-voice]');
if (!select) return;
const voices = engine.snapshot.voices;
const engine = this.tts;
const voices = engine?.snapshot.voices ?? [];
const signature = voices.map((voice) => voice.id).join('|');
if (select.dataset['signature'] === signature) return;
select.dataset['signature'] = signature;
@@ -668,6 +720,27 @@ export class ReaderScreen {
}
}
/**
* Probes the server for an HTTP voice engine, once per screen.
*
* Not awaited by the settings panel: a probe against a NAS on a slow link must
* not delay the panel opening. The reply rebuilds the engine row when it
* arrives, which is why `ttsEngine` defaults to `auto` — the reader never has
* to wait for the answer to start listening.
*/
private async probeHttpTts(): Promise<void> {
try {
const capabilities = await this.options.api.ttsCapabilities();
this.httpTtsAvailable = capabilities?.http === true;
} catch {
this.httpTtsAvailable = false;
}
// Rebuilt either way: the answer decides whether the engine picker appears at
// all, and a panel built before the probe would be missing the row that the
// reader is looking for.
this.rebuildSpeechEngineRow();
}
/**
* The contents panel.
*
@@ -845,7 +918,10 @@ export class ReaderScreen {
// -- 朗读 --
body.append(this.sectionTitle('朗读'));
body.append(this.buildSpeechSettings());
// Wrapped in its own element so the engine picker can rebuild the speech rows
// in place when the HTTP capability probe answers, without discarding whatever
// the reader has scrolled to in the rest of the panel.
body.append(el('div', { dataset: { speech: 'group' }, children: [this.buildSpeechSettings()] }));
// -- 文本 --
const encodingRow = this.settingsRow('TXT 编码', ['', 'utf-8', 'gb18030', 'big5', 'utf-16le'], this.settings.txtEncoding, (value) =>
@@ -887,7 +963,7 @@ export class ReaderScreen {
*/
private recordSpeechAnchor(): void {
const view = this.view;
if (!view || !TtsEngine.isSupported()) return;
if (!view) return;
// The engine owns the cursor once it is running; re-anchoring then would make
// "read from here" jump to wherever the reader last scrolled.
if (this.tts?.active) return;
@@ -897,48 +973,78 @@ export class ReaderScreen {
/**
* The read-aloud section of the settings panel.
*
* The voice list is read from the engine *when the section is built*, which is
* the first moment it can be trusted: Chrome populates `getVoices()` only after
* `voiceschanged`, and a panel built before that would ship an empty picker.
* Which rows exist depends on the engine, and the dependency is stated rather
* than hidden: pitch has no meaning for synthesised audio, a voice list is empty
* for HTTP until the server answers, and "系统语音(原生)" does not exist in a
* browser. A row that cannot do anything is hidden instead of being shown
* disabled — a disabled control tells the reader something is broken, and
* usually nothing is.
*/
private buildSpeechSettings(): DocumentFragment {
const fragment = document.createDocumentFragment();
if (!TtsEngine.isSupported()) {
const availability = this.speechAvailability();
if (!availability.preferred) {
fragment.append(
el('div', {
className: 'notice',
text: '当前环境不支持系统朗读。Android 客户端会使用系统 TTS 引擎,浏览器中请使用 Chrome / Edge。',
text: '当前环境没有可用的朗读引擎:浏览器不支持语音合成,且服务端未配置 TTS_URL。在服务端设置 TTS_URL 后即可使用 HTTP 朗读。',
}),
);
return fragment;
}
// The engine picker only appears when there is a choice to make. One engine
// is not a setting.
const engineCount = [availability.system, availability.native, availability.http].filter(Boolean).length;
if (engineCount > 1) {
const options: Array<{ value: string; label: string }> = [{ value: 'auto', label: '自动' }];
if (availability.native) options.push({ value: 'native', label: SPEECH_ENGINE_LABELS.native });
if (availability.system) options.push({ value: 'system', label: SPEECH_ENGINE_LABELS.system });
if (availability.http) options.push({ value: 'http', label: SPEECH_ENGINE_LABELS.http });
const row = this.selectRow('朗读引擎', options, this.settings.ttsEngine, (value) =>
this.switchSpeechEngine(value as AppSettings['ttsEngine']));
row.dataset['speech'] = 'engine';
fragment.append(row);
}
fragment.append(
this.slider('语速', this.settings.ttsRate, { min: 0.5, max: 2.5, step: 0.1 }, (value) =>
`${value.toFixed(1)}×`, (value) => this.updateSpeechSetting({ ttsRate: value })),
);
fragment.append(
this.slider('音调', this.settings.ttsPitch, { min: 0.5, max: 2, step: 0.1 }, (value) =>
value.toFixed(1), (value) => this.updateSpeechSetting({ ttsPitch: value })),
);
// Pitch is offered only for the engines that have one. `HttpTtsEngine` has no
// pitch control that is not a rate change, and a shimmed one is worse than
// none — so the row is omitted rather than left to do nothing.
const active = this.effectiveEngineKind() ?? availability.preferred;
if (active !== 'http') {
const row = this.slider('音调', this.settings.ttsPitch, { min: 0.5, max: 2, step: 0.1 }, (value) =>
value.toFixed(1), (value) => this.updateSpeechSetting({ ttsPitch: value }));
row.dataset['speech'] = 'pitch';
fragment.append(row);
}
fragment.append(
this.slider('音量', this.settings.ttsVolume, { min: 0, max: 1, step: 0.05 }, (value) =>
`${Math.round(value * 100)}%`, (value) => this.updateSpeechSetting({ ttsVolume: value })),
);
const engine = this.ensureTts();
const voices = engine.snapshot.voices;
const options: Array<{ value: string; label: string }> = [
{ value: '', label: '跟随系统' },
...voices.map((voice) => ({
value: voice.id,
label: `${voice.name} · ${voice.lang}${voice.default ? ' · 默认' : ''}`,
})),
];
fragment.append(
this.selectRow('语音', options, this.settings.ttsVoice, (value) =>
this.updateSpeechSetting({ ttsVoice: value })),
);
// The voice list belongs to the system and native engines; HTTP uses whatever
// voice id was configured upstream, and the server publishes those separately.
if (active !== 'http') {
const engine = this.tts;
const voices = engine?.snapshot.voices ?? [];
const options: Array<{ value: string; label: string }> = [
{ value: '', label: '跟随系统' },
...voices.map((voice) => ({
value: voice.id,
label: `${voice.name} · ${voice.lang}${voice.default ? ' · 默认' : ''}`,
})),
];
const row = this.selectRow('语音', options, this.settings.ttsVoice, (value) =>
this.updateSpeechSetting({ ttsVoice: value }));
row.dataset['speech'] = 'voice';
fragment.append(row);
}
fragment.append(
this.settingsRow('章节播完', ['auto', 'stop'], this.settings.ttsAutoAdvance ? 'auto' : 'stop', (value) =>
@@ -958,6 +1064,52 @@ export class ReaderScreen {
return fragment;
}
/**
* Switches engines, discarding whatever the old one was saying.
*
* Stopping first is not optional: an `Audio` element and `speechSynthesis` will
* both happily keep talking, and two engines reading the same chapter in
* different voices is the kind of bug that is reported as "朗读疯了".
*/
private switchSpeechEngine(kind: AppSettings['ttsEngine']): void {
this.tts?.dispose();
this.tts = null;
this.ttsBar.hidden = true;
this.view?.clearSpeechHighlight();
void this.updateSpeechSetting({ ttsEngine: kind });
// Rebuild only the speech rows, so the reader's scroll position in the panel
// is not thrown away by a full rebuild.
const host = this.settingsPanel.querySelector<HTMLElement>('[data-speech="group"]');
if (host) {
host.replaceChildren(this.buildSpeechSettings());
this.filterSettingsRows();
}
}
/**
* Rebuilds the speech rows.
*
* Called when the HTTP capability probe answers and when the reader switches
* engines. Only the speech group is replaced: a full panel rebuild would throw
* away the reader's scroll position, and the panel is long enough that this is
* the difference between a settings screen and a settings screen that jumps.
*/
private rebuildSpeechEngineRow(): void {
const host = this.settingsPanel.querySelector<HTMLElement>('[data-speech="group"]');
if (!host) return;
host.replaceChildren(this.buildSpeechSettings());
this.filterSettingsRows();
}
/** Which engine will actually speak, for the panel's conditional rows. */
private effectiveEngineKind(): Exclude<SpeechEngineKind, 'auto'> | null {
if (this.tts) return this.tts.kind;
const availability = this.speechAvailability();
const requested = this.settings.ttsEngine;
if (requested !== 'auto' && availability[requested]) return requested;
return availability.preferred;
}
private sectionTitle(text: string): HTMLDivElement {
return el('div', { className: 'section-title', text }) as HTMLDivElement;
}
@@ -1102,18 +1254,71 @@ export class ReaderScreen {
}) as HTMLDivElement;
}
private ensureTts(): TtsEngine {
/**
* The engine that will speak, built on first use.
*
* `auto` resolves against what this host actually has, in the order
* `speechAvailability` documents: the native engine (an Android shell), then
* the WebView's own synthesizer, then HTTP. A pinned choice is honoured when it
* exists and falls back silently when it does not — a reader who chose "HTTP
* 朗读" on a server that has since lost its `TTS_URL` should still be able to
* listen rather than be told their preference is invalid.
*/
private ensureTts(): SpeechEngine | null {
if (this.tts) return this.tts;
this.tts = new TtsEngine({
loadQueue: (from) => this.loadSpeechQueue(from),
onChunk: (chunk) => this.view?.highlightSpokenChunk(chunk),
onState: (snapshot) => this.renderSpeechState(snapshot),
const availability = this.speechAvailability();
const requested = this.settings.ttsEngine;
const kind: Exclude<SpeechEngineKind, 'auto'> | null =
requested === 'auto'
? availability.preferred
: availability[requested]
? requested
: availability.preferred;
if (!kind) return null;
const engine = createSpeechEngine({
kind,
baseUrl: this.options.api.baseUrl,
accessToken: () => this.options.api.currentSession()?.accessToken ?? null,
nativeBridge: this.options.speechBridge ?? null,
onError: (message) => {
this.setStatus('error', message);
window.setTimeout(() => this.hideStatus(), 3200);
},
});
if (!engine) return null;
engine.loadQueue = (from) => this.loadSpeechQueue(from);
engine.onChunk = (chunk) => {
this.view?.highlightSpokenChunk(chunk);
this.speechCursor = chunk;
};
// The HTTP adapter holds the queue the engine is speaking from, so it can map
// an engine index back to the sentence the reader can see — the two differ
// when a long sentence had to be split. Only the screen layer knows the list,
// which is why it is handed over rather than duplicated.
if (engine.kind === 'http' && 'chunkSource' in engine) {
(engine as { chunkSource: () => SpokenChunk[] }).chunkSource = () => this.lastSpeechQueue;
}
engine.onState = (snapshot) => this.renderSpeechState(snapshot);
engine.setRate(this.settings.ttsRate);
engine.setPitch(this.settings.ttsPitch);
engine.setVolume(this.settings.ttsVolume);
engine.setVoice(this.settings.ttsVoice);
this.tts = engine;
return engine;
}
/** The sentence last handed to the engine, so `previous` can step back from it. */
private speechCursor: SpokenChunk | null = null;
/** What this host can speak with. */
private speechAvailability(): ReturnType<typeof speechAvailability> {
return speechAvailability({
systemSupported: TtsEngine.isSupported(),
nativeBridge: this.options.speechBridge ?? null,
httpConfigured: this.httpTtsAvailable,
});
this.tts.setRate(this.settings.ttsRate);
this.tts.setPitch(this.settings.ttsPitch);
this.tts.setVolume(this.settings.ttsVolume);
this.tts.setVoice(this.settings.ttsVoice);
return this.tts;
}
/**
@@ -1126,7 +1331,7 @@ export class ReaderScreen {
private async loadSpeechQueue(from: number): Promise<{ chunks: SpokenChunk[]; startIndex: number }> {
const view = this.view;
if (!view) return { chunks: [], startIndex: 0 };
let chunks = view.speechChunks as SpokenChunk[];
let chunks = this.rememberSpeechChunks(view);
if (from < chunks.length && view.currentSectionIndex() === this.spokenSectionIndex) {
return { chunks, startIndex: from };
}
@@ -1138,17 +1343,45 @@ export class ReaderScreen {
if (next >= view.sectionCount) return { chunks: [], startIndex: 0 };
await view.open(next, 0);
this.spokenSectionIndex = next;
chunks = view.speechChunks as SpokenChunk[];
chunks = this.rememberSpeechChunks(view);
return { chunks, startIndex: 0 };
}
/**
* Caches the chapter's sentence list and trims it for the engine in use.
*
* The trim is what makes the HTTP engine work at all: the server refuses an
* utterance over 800 characters, and a TXT chapter with no punctuation in it
* produces exactly one of those. Splitting a long sentence into ≤700-character
* pieces *for the engine only* keeps the highlight anchored to the original
* chunk, because `speechQueue` still holds the untrimmed list and the index
* arithmetic is done against that.
*/
private rememberSpeechChunks(view: ReaderView): SpokenChunk[] {
const chunks = view.speechChunks as SpokenChunk[];
this.lastSpeechQueue = chunks;
if (this.tts?.kind !== 'http') return chunks;
const max = 700;
const trimmed: SpokenChunk[] = [];
for (const chunk of chunks) {
if (chunk.text.length <= max) {
trimmed.push(chunk);
continue;
}
for (let offset = 0; offset < chunk.text.length; offset += max) {
trimmed.push({ ...chunk, text: chunk.text.slice(offset, offset + max), start: chunk.start + offset });
}
}
return trimmed;
}
private toggleSpeech(): void {
if (!TtsEngine.isSupported()) {
this.setStatus('error', '当前浏览器不支持朗读');
const engine = this.ensureTts();
if (!engine) {
this.setStatus('error', '当前环境没有可用的朗读引擎');
window.setTimeout(() => this.hideStatus(), 2600);
return;
}
const engine = this.ensureTts();
if (engine.active) {
if (this.ttsBar.dataset['state'] === 'playing') engine.pause();
else engine.resume();
@@ -1176,9 +1409,14 @@ export class ReaderScreen {
const view = this.view;
if (!view) return;
const engine = this.ensureTts();
if (!engine) {
this.setStatus('error', '当前环境没有可用的朗读引擎');
window.setTimeout(() => this.hideStatus(), 2600);
return;
}
this.ttsBar.hidden = false;
this.spokenSectionIndex = view.currentSectionIndex();
const chunks = view.speechChunks as SpokenChunk[];
const chunks = this.rememberSpeechChunks(view);
if (chunks.length === 0) {
// A fixed-layout page has no text; say so instead of showing an empty bar.
this.setStatus('idle', '这一页没有可朗读的文字');
@@ -1195,10 +1433,29 @@ export class ReaderScreen {
await engine.play(start >= 0 ? start : 0);
}
/**
* The index of the sentence being spoken, on the *untrimmed* queue.
*
* The engine may be speaking a fragment of a long sentence (see
* `rememberSpeechChunks`), and the reader's idea of "the current sentence" is
* the whole one. Mapping back through the original chunk is what makes the
* progress bar's denominator match the highlight.
*/
private speechQueueIndex(): number {
const chunk = this.speechCursor;
if (!chunk) return -1;
// Searched on the engine's fragment list, because that is the list the index
// refers to; the fragment for a split sentence carries the original sentence's
// `start`, so identity comparison against the engine's own list is exact.
return this.lastSpeechQueue.indexOf(chunk);
}
private onSpeechScrub(index: number): void {
const engine = this.tts;
if (!engine) return;
const total = engine.snapshot.total;
// The engine's queue is the one being spoken, so the jump is by its index;
// the two only differ when a sentence had to be split for the HTTP engine.
// Scrubbing to the very end means "stop", not "play the last sentence".
if (total === 0 || index >= total) {
this.stopSpeech();
@@ -1222,8 +1479,12 @@ export class ReaderScreen {
this.ttsLabel.title = snapshot.chunk;
this.ttsChip.textContent = snapshot.total > 0 ? `${snapshot.index + 1}/${snapshot.total}` : '从头朗读';
this.ttsChip.setAttribute('aria-label', snapshot.total > 0 ? '朗读句数' : '从头朗读');
this.ttsRangeInput.max = String(Math.max(0, snapshot.total - 1));
this.ttsRangeInput.value = String(Math.max(0, snapshot.index));
// The scrub range is expressed in *sentences*, not in engine fragments, so a
// chapter with a 2000-character paragraph still shows one tick for it.
const sentenceIndex = this.speechQueueIndex();
const sentenceTotal = this.lastSpeechQueue.length || snapshot.total;
this.ttsRangeInput.max = String(Math.max(0, sentenceTotal - 1));
this.ttsRangeInput.value = String(Math.max(0, sentenceIndex >= 0 ? sentenceIndex : snapshot.index));
if (snapshot.error) {
this.setStatus('error', snapshot.error);
window.setTimeout(() => this.hideStatus(), 3200);
+60
View File
@@ -0,0 +1,60 @@
/**
* Shelf ordering, in one place.
*
* Extracted from the screen because it has to agree with the server: the client
* sorts its *cached* shelf while offline and the server sorts the real one, and a
* library that reorders itself when a phone goes through a tunnel reads as the app
* having lost the reader's choice. Keeping both rules in one function is how they
* stay the same rule.
*
* `localeCompare` with `numeric: true` is the part that matters for Chinese
* libraries: a plain string compare puts 第10章 before 第9章, because '1' < '9'.
*/
import type { Book } from '../api/types.ts';
import type { ShelfSort } from '../store/settings.ts';
/**
* Direction for a sort key.
*
* Title and author ascend — a reader who asks for "by title" means A→Z. The date
* sorts descend, because "recent" means newest first, and nobody has ever asked
* for their oldest book at the top of the shelf.
*/
export function shelfOrder(sort: ShelfSort): 'asc' | 'desc' {
return sort === 'title' || sort === 'author' ? 'asc' : 'desc';
}
/** A book's sortable key, as a string so every branch compares the same way. */
export function shelfSortKey(book: Book, sort: ShelfSort): string {
switch (sort) {
case 'title':
return book.title ?? '';
case 'author':
return book.author ?? '';
case 'added':
// `addedAt` is when this account first saw the book; `updatedAt` moves when
// a file changes on disk. They are different questions and the shelf offers
// both.
return String(book.addedAt ?? book.updatedAt ?? 0).padStart(20, '0');
default:
return String(book.updatedAt ?? 0).padStart(20, '0');
}
}
export function compareBooks(left: Book, right: Book, sort: ShelfSort): number {
const direction = shelfOrder(sort) === 'asc' ? 1 : -1;
const result = shelfSortKey(left, sort).localeCompare(shelfSortKey(right, sort), 'zh-Hans-CN', {
numeric: true,
sensitivity: 'base',
});
// A stable tie-break on id: without it, two books added in the same second
// could swap places between two renders, which looks like the shelf shuffling
// itself under the reader's thumb.
if (result !== 0) return result * direction;
return left.id.localeCompare(right.id);
}
export function sortBooks(books: Book[], sort: ShelfSort): Book[] {
return [...books].sort((left, right) => compareBooks(left, right, sort));
}
+261 -23
View File
@@ -1,18 +1,33 @@
import { ApiError } from '../api/errors.ts';
import type { ReaderApi } from '../api/client.ts';
import type { ListQuery, ReaderApi } from '../api/client.ts';
import type { Book, ContinueReadingItem } from '../api/types.ts';
import type { OfflineStore } from '../store/offline.ts';
import type { Platform } from '../core/platform.ts';
import type { AppSettings, ShelfSort } from '../store/settings.ts';
import { clear, el, percent } from './dom.ts';
import { DENSITY_LABELS, buildShelfSettingsPanel } from './shelf-settings.ts';
import { sortBooks, shelfOrder } from './shelf-order.ts';
export interface ShelfScreenOptions {
api: ReaderApi;
offline: OfflineStore;
platform: Platform;
/** Current shelf preferences, so the screen starts in the reader's own state. */
settings: AppSettings;
onOpenBook(book: Book): void;
onSignedOut(): void;
/** Persisted through the app's settings store, like the reader's own. */
onSettingsChange(patch: Partial<AppSettings>): void;
}
/** Sort options, in the order the sheet shows them. */
const SORTS: Array<{ value: ShelfSort; label: string }> = [
{ value: 'updated', label: '最近更新' },
{ value: 'added', label: '最近入库' },
{ value: 'title', label: '书名' },
{ value: 'author', label: '作者' },
];
/**
* The shelf.
*
@@ -20,6 +35,27 @@ export interface ShelfScreenOptions {
* first and only then replaces it with server data. On a LAN or with a cold
* cache that is invisible; on a train it is the difference between the library
* appearing instantly and an indefinite spinner (product design §8.2).
*
* ## What was added, and the rule behind each
*
* The shelf is the most-opened screen in the app and was the least configurable,
* which is an odd pairing: a reader who opens it forty times a day is the one who
* notices that the covers are too small, that the list is in the wrong order, and
* that clearing a search takes three taps. Every addition below follows one rule —
* **it must be a thing a reader would change twice**, because a settings screen
* full of switches nobody flips is worse than no settings screen.
*
* - **Sort, as a control on the shelf** rather than only in settings, because
* "show me what I just added" is a per-session intent, not a preference.
* - **Density and author visibility in settings**, because those *are*
* preferences: they are about the screen and the eyes, and they persist.
* - **A clear button in the search field**, because on a phone the alternative is
* selecting four characters with a fat-finger cursor.
* - **Progress on the cover**, because the one question a shelf answers for a
* reader who has twenty books going is "where was I".
* - **A pull-to-refresh at the top**, because the shelf is now the only place a
* scan's result becomes visible, and "I added a book, why is it not here" is
* the complaint that a manual refresh answers.
*/
export class ShelfScreen {
readonly element: HTMLDivElement;
@@ -28,15 +64,30 @@ export class ShelfScreen {
private readonly continueSection: HTMLDivElement;
private readonly statusLine: HTMLDivElement;
private readonly searchInput: HTMLInputElement;
private readonly searchClear: HTMLButtonElement;
private readonly countLabel: HTMLDivElement;
private readonly sortRow: HTMLDivElement;
private readonly refreshBar: HTMLDivElement;
private readonly settingsButton: HTMLButtonElement;
private readonly settingsPanel: HTMLDivElement;
private extraLoading: HTMLDivElement | null = null;
private query = '';
private offset = 0;
private total = 0;
private loading = false;
/** Session sort; seeded from settings and written back when it changes. */
private sort: ShelfSort;
private readonly searchTimer: { id: ReturnType<typeof setTimeout> | null } = { id: null };
private readonly coverUrls = new Map<string, string>();
private readonly settings: AppSettings;
private readonly scroll: HTMLDivElement;
/** Scroll position of the previous render, so a refresh does not jump. */
private lastScrollTop = 0;
constructor(private readonly options: ShelfScreenOptions) {
this.settings = { ...options.settings };
this.sort = options.settings.shelfSort;
this.searchInput = el('input', {
attrs: {
type: 'search',
@@ -46,44 +97,108 @@ export class ShelfScreen {
},
on: {
input: () => this.onSearchInput(),
keydown: (event) => {
// `search` inputs fire a non-standard `search` event on clear, but only
// in some browsers; Enter is the one that is reliable everywhere and it
// also means "stop waiting for the debounce".
if ((event as KeyboardEvent).key === 'Enter') {
if (this.searchTimer.id !== null) clearTimeout(this.searchTimer.id);
this.searchTimer.id = null;
this.query = this.searchInput.value.trim();
void this.refresh();
}
},
},
});
}) as HTMLInputElement;
this.searchClear = el('button', {
className: 'search-clear',
text: '✕',
attrs: { type: 'button', 'aria-label': '清除搜索', hidden: true },
on: {
click: () => {
this.searchInput.value = '';
this.query = '';
this.searchClear.hidden = true;
if (this.searchTimer.id !== null) clearTimeout(this.searchTimer.id);
this.searchTimer.id = null;
void this.refresh();
},
},
}) as HTMLButtonElement;
this.continueRow = el('div', { className: 'continue-row' });
this.continueSection = el('div', { attrs: { hidden: true } }) as HTMLDivElement;
const continueTitle = el('div', { className: 'shelf-section-title', text: '继续阅读' });
this.continueSection.append(continueTitle, this.continueRow);
this.countLabel = el('div', { className: 'shelf-count muted' }) as HTMLDivElement;
this.sortRow = el('div', { className: 'shelf-sort' }) as HTMLDivElement;
this.buildSortRow();
this.settingsButton = el('button', {
className: 'icon-button shelf-settings-button',
text: '⚙',
attrs: { type: 'button', 'aria-label': '书架设置' },
on: { click: () => this.toggleSettings() },
}) as HTMLButtonElement;
const toolbar = el('div', {
className: 'shelf-toolbar',
children: [this.countLabel, this.sortRow, this.settingsButton],
});
this.settingsPanel = buildShelfSettingsPanel({
settings: this.settings,
onPatch: (patch) => {
Object.assign(this.settings, patch);
this.options.onSettingsChange(patch);
this.applyDensity();
this.applyAppearance();
},
onClose: () => {
this.settingsPanel.hidden = true;
},
});
this.grid = el('div', { className: 'book-grid' });
this.statusLine = el('div', { className: 'shelf-status muted' });
this.refreshBar = el('div', { className: 'shelf-refresh', attrs: { hidden: true } }) as HTMLDivElement;
const shelf = el('div', { className: 'shelf' });
shelf.append(
el('div', { className: 'shelf-search', children: [this.searchInput] }),
el('div', { className: 'shelf-search', children: [this.searchInput, this.searchClear] }),
this.continueSection,
toolbar,
this.refreshBar,
this.grid,
this.statusLine,
this.settingsPanel,
);
(shelf as HTMLDivElement).id = 'shelf-scroll';
this.scroll = shelf as HTMLDivElement;
this.element = el('div', { className: 'shelf-screen', children: [shelf] }) as HTMLDivElement;
this.element.style.cssText = 'flex:1 1 auto; min-height:0; display:flex; flex-direction:column;';
this.applyDensity();
shelf.addEventListener('scroll', () => {
const target = shelf as HTMLDivElement;
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 400) {
void this.loadMore();
}
});
this.scroll.addEventListener('scroll', () => this.onScroll());
}
async show(): Promise<void> {
await this.options.offline.load();
const cached = this.options.offline.books();
if (cached.length > 0) this.renderItems(cached, true);
if (cached.length > 0) this.renderItems(this.sorted(cached), true);
await this.refresh();
}
/**
* Refetches the first page and the "continue reading" row.
*
* Both in one `Promise.all`, because they are two halves of one screen: fetching
* them in sequence made the shelf appear and *then* a row appear above it,
* pushing everything down under the reader's thumb.
*/
async refresh(): Promise<void> {
try {
const [page, continueItems] = await Promise.all([
@@ -93,24 +208,39 @@ export class ShelfScreen {
this.offset = page.items.length;
this.total = page.total;
await this.options.offline.replaceBooks(page.items);
this.renderItems(page.items, true);
this.renderItems(this.sorted(page.items), true);
this.renderContinue(continueItems);
this.renderCount();
this.setStatus('');
this.scroll.scrollTop = this.lastScrollTop;
} catch (err) {
this.handleError(err);
}
}
private listQuery(page: number): { search?: string; page: number; pageSize: number } {
private listQuery(page: number): ListQuery {
return {
...(this.query ? { search: this.query } : {}),
sort: this.sort,
order: shelfOrder(this.sort),
page,
pageSize: 60,
};
}
/**
* Orders the cached shelf the same way the server would.
*
* The offline path has to answer the same question the online one does, so the
* rule lives in `shelf-order.ts` and is shared by both.
*/
private sorted(books: Book[]): Book[] {
return sortBooks(books, this.sort);
}
private onSearchInput(): void {
this.query = this.searchInput.value.trim();
this.searchClear.hidden = this.query.length === 0;
if (this.searchTimer.id !== null) clearTimeout(this.searchTimer.id);
// Debounced: a shelf of 2000 books over a LAN is fine, but a mobile network
// plus a keystroke per character is not.
@@ -120,6 +250,36 @@ export class ShelfScreen {
}, 250);
}
private onScroll(): void {
this.lastScrollTop = this.scroll.scrollTop;
if (this.scroll.scrollTop + this.scroll.clientHeight >= this.scroll.scrollHeight - 400) {
void this.loadMore();
}
// Pull-to-refresh fires only when the reader has already overscrolled and lets
// go — a refresh on every scroll-to-top would fire constantly on a phone.
if (this.scroll.scrollTop < 24 && this.atTopSince !== null && Date.now() - this.atTopSince > 400) {
this.atTopSince = null;
void this.manualRefresh();
}
}
/** Timestamp of when the shelf first reached the top, for pull-to-refresh. */
private atTopSince: number | null = null;
private async manualRefresh(): Promise<void> {
this.refreshBar.hidden = false;
this.refreshBar.textContent = '正在刷新…';
try {
await this.refresh();
this.refreshBar.textContent = '已刷新';
} catch {
this.refreshBar.textContent = '刷新失败';
}
setTimeout(() => {
this.refreshBar.hidden = true;
}, 900);
}
private async loadMore(): Promise<void> {
if (this.loading || this.offset >= this.total) return;
this.loading = true;
@@ -130,7 +290,8 @@ export class ShelfScreen {
this.offset += result.items.length;
this.total = result.total;
await this.options.offline.replaceBooks(result.items);
this.appendItems(result.items);
this.appendItems(this.sorted(result.items), false);
this.renderCount();
} catch (err) {
if (!(err instanceof ApiError) || !err.isConnectivity) this.setStatus('加载更多失败');
} finally {
@@ -142,7 +303,7 @@ export class ShelfScreen {
private showLoadingMore(): void {
if (this.extraLoading) return;
this.extraLoading = el('div', { className: 'spinner' });
this.element.querySelector('.shelf')?.append(this.extraLoading);
this.scroll.append(this.extraLoading);
}
private hideLoadingMore(): void {
@@ -150,6 +311,71 @@ export class ShelfScreen {
this.extraLoading = null;
}
private renderCount(): void {
if (this.query) {
this.countLabel.textContent = `找到 ${this.total} 本`;
return;
}
this.countLabel.textContent = this.total > 0 ? `共 ${this.total} 本` : '';
}
/**
* The sort control.
*
* A row of chips rather than a `<select>`: four options is few enough to show
* all of them, and a picker that requires opening a sheet to answer "what order
* is this in" hides the answer at exactly the moment the reader is asking.
*/
private buildSortRow(): void {
clear(this.sortRow);
const label = el('span', { className: 'shelf-sort-label muted', text: '排序' });
this.sortRow.append(label);
for (const option of SORTS) {
const active = option.value === this.sort;
this.sortRow.append(
el('button', {
className: 'chip',
text: option.label,
attrs: { type: 'button', 'aria-pressed': String(active), 'aria-label': `按${option.label}排序` },
on: {
click: () => {
if (this.sort === option.value) return;
this.sort = option.value;
// Written back so the choice survives a restart. It is a preference
// as much as it is a per-session intent, and the two cannot be told
// apart — so persisting is the less surprising of the two harms.
this.options.onSettingsChange({ shelfSort: this.sort });
this.settings.shelfSort = this.sort;
this.buildSortRow();
// Ordering changes the whole result set, so a refresh rather than a
// client-side re-sort: the server is the authority, and it is the
// only side that can re-sort a library larger than one page.
this.lastScrollTop = 0;
void this.refresh();
},
},
}),
);
}
}
private toggleSettings(): void {
this.settingsPanel.hidden = !this.settingsPanel.hidden;
}
/** Applies the density setting to the grid, by class rather than inline style. */
applyDensity(): void {
this.grid.dataset['density'] = this.settings.shelfDensity;
// The label is mirrored onto the control so the sheet does not have to be
// reopened to see what is set.
this.settingsButton.setAttribute('aria-label', `书架设置 · ${DENSITY_LABELS[this.settings.shelfDensity]}`);
}
applyAppearance(): void {
this.grid.dataset['showAuthor'] = String(this.settings.shelfShowAuthor);
this.grid.dataset['showProgress'] = String(this.settings.shelfShowProgress);
}
private renderItems(items: Book[], replace: boolean): void {
if (replace) {
clear(this.grid);
@@ -157,7 +383,8 @@ export class ShelfScreen {
// shelf and a filtered response would merge into one confusing list.
this.rendered.clear();
}
this.appendItems(items);
this.applyAppearance();
this.appendItems(items, replace);
if (this.rendered.size === 0) {
this.grid.append(
el('div', {
@@ -173,7 +400,7 @@ export class ShelfScreen {
private readonly rendered = new Set<string>();
private appendItems(items: Book[]): void {
private appendItems(items: Book[], _replace: boolean): void {
for (const book of items) {
if (this.rendered.has(book.id)) continue;
this.rendered.add(book.id);
@@ -185,7 +412,16 @@ export class ShelfScreen {
const cover = el('div', { className: 'cover' });
void this.fillCover(cover, book);
const card = el('button', {
// The progress bar is drawn from the local mirror, which the sync engine
// keeps current — so it is correct offline, with no extra request per card.
const progress = this.options.offline.progressFor(book.id);
if (progress && progress.percentage > 0.005) {
const track = el('div', { className: 'cover-progress', children: [el('span')] });
(track.querySelector('span') as HTMLSpanElement).style.width = percent(progress.percentage);
cover.append(track);
}
return el('button', {
className: 'book-card',
attrs: { type: 'button', 'aria-label': `${book.title} ${book.author}`.trim() },
on: { click: () => this.options.onOpenBook(book) },
@@ -194,20 +430,20 @@ export class ShelfScreen {
el('div', { className: 'title', text: book.title || '未命名' }),
el('div', { className: 'author', text: book.author || '未知作者' }),
],
});
return card as HTMLButtonElement;
}) as HTMLButtonElement;
}
private async fillCover(container: HTMLDivElement, book: Book): Promise<void> {
const badge = el('span', { className: 'format-badge', text: book.format });
if (!book.coverUrl) {
container.append(el('div', { className: 'placeholder', text: book.title.slice(0, 12) || '无封面' }));
container.append(el('span', { className: 'format-badge', text: book.format }));
container.append(badge);
return;
}
const cached = this.coverUrls.get(book.id);
if (cached) {
container.append(el('img', { attrs: { src: cached, alt: '', loading: 'lazy' } }));
container.append(el('span', { className: 'format-badge', text: book.format }));
container.append(badge);
return;
}
try {
@@ -216,10 +452,10 @@ export class ShelfScreen {
this.coverUrls.set(book.id, url);
clear(container);
container.append(el('img', { attrs: { src: url, alt: '', loading: 'lazy' } }));
container.append(el('span', { className: 'format-badge', text: book.format }));
container.append(badge);
} catch {
container.append(el('div', { className: 'placeholder', text: book.title.slice(0, 12) || '无封面' }));
container.append(el('span', { className: 'format-badge', text: book.format }));
container.append(badge);
}
}
@@ -279,6 +515,8 @@ export class ShelfScreen {
const cached = this.options.offline.books();
if (cached.length > 0) {
this.setStatus(`离线模式 · ${cached.length} 本可读`);
this.renderItems(this.sorted(cached), true);
this.renderCount();
return;
}
this.setStatus('连不上服务端');
+158
View File
@@ -0,0 +1,158 @@
/**
* The shelf's own settings.
*
* A sheet rather than a route, and a separate file rather than more rows in the
* reader's panel — because the two answer different questions. The reader panel
* is about the *page*: type size, line height, the engine that reads it aloud.
* This one is about the *grid*: how many covers fit, what each cover shows, and
* what order they are in.
*
* Keeping them apart is not tidiness. A reader who wants bigger covers should not
* have to open a panel full of typography settings to find it, and the reader's
* panel is already four sections long.
*/
import type { AppSettings } from '../store/settings.ts';
import { el } from './dom.ts';
export const DENSITY_LABELS: Record<AppSettings['shelfDensity'], string> = {
compact: '紧凑',
cozy: '适中',
comfortable: '宽松',
};
export interface ShelfSettingsOptions {
settings: AppSettings;
onPatch(patch: Partial<AppSettings>): void;
onClose(): void;
}
/**
* Builds the sheet.
*
* Returns the panel element, hidden. Rows are built once and mutate the settings
* object in place: this is a form over a handful of values, and rebuilding it on
* every toggle would fight the reader's own tap (the button they just pressed
* would be replaced under their finger).
*/
export function buildShelfSettingsPanel(options: ShelfSettingsOptions): HTMLDivElement {
const body = el('div', { className: 'panel-body' });
body.append(
el('div', {
className: 'section-title',
text: '封面大小',
}),
);
body.append(
segmented(
(['compact', 'cozy', 'comfortable'] as const).map((value) => ({
value,
label: DENSITY_LABELS[value],
})),
options.settings.shelfDensity,
(value) => options.onPatch({ shelfDensity: value as AppSettings['shelfDensity'] }),
),
);
body.append(el('div', { className: 'section-title', text: '显示' }));
body.append(
toggleRow('显示作者', '关闭后每本书只显示书名,封面可以更小', options.settings.shelfShowAuthor, (value) =>
options.onPatch({ shelfShowAuthor: value }),
),
);
body.append(
toggleRow('显示阅读进度', '在封面底部画一条进度条', options.settings.shelfShowProgress, (value) =>
options.onPatch({ shelfShowProgress: value }),
),
);
body.append(el('div', { className: 'section-title', text: '默认排序' }));
body.append(
el('div', {
className: 'notice',
text: '书架上的排序按钮会记住最后一次选择,这里设置的是首次打开时的默认值。',
}),
);
body.append(
segmented(
[
{ value: 'updated', label: '最近更新' },
{ value: 'added', label: '最近入库' },
{ value: 'title', label: '书名' },
{ value: 'author', label: '作者' },
],
options.settings.shelfSort,
(value) => options.onPatch({ shelfSort: value as AppSettings['shelfSort'] }),
),
);
return el('div', {
className: 'panel shelf-settings',
attrs: { hidden: true },
children: [
el('div', {
className: 'panel-header',
children: [
el('h2', { text: '书架设置' }),
el('button', {
className: 'icon-button',
text: '✕',
attrs: { type: 'button', 'aria-label': '关闭' },
on: { click: options.onClose },
}),
],
}),
body,
],
}) as HTMLDivElement;
}
function segmented(
values: Array<{ value: string; label: string }>,
current: string,
onChange: (value: string) => void,
): HTMLDivElement {
const buttons = values.map((entry) =>
el('button', {
text: entry.label,
attrs: { type: 'button', 'aria-pressed': String(entry.value === current) },
on: {
click: (event) => {
const group = (event.currentTarget as HTMLElement).parentElement;
for (const sibling of group?.children ?? []) {
sibling.setAttribute('aria-pressed', String(sibling === event.currentTarget));
}
onChange(entry.value);
},
},
}),
);
return el('div', { className: 'segmented', children: buttons }) as HTMLDivElement;
}
/**
* A switch.
*
* `aria-pressed` rather than a checkbox: the row is the hit target (44px), and a
* checkbox would put the target on a 16px square next to a label that does
* nothing when tapped.
*/
function toggleRow(label: string, hint: string, value: boolean, onChange: (value: boolean) => void): HTMLDivElement {
const button = el('button', {
className: 'switch',
attrs: { type: 'button', 'aria-pressed': String(value) },
on: {
click: (event) => {
const next = (event.currentTarget as HTMLElement).getAttribute('aria-pressed') !== 'true';
(event.currentTarget as HTMLElement).setAttribute('aria-pressed', String(next));
onChange(next);
},
},
});
const text = el('div', { className: 'switch-text', children: [el('div', { text: label }), el('div', { className: 'muted', text: hint })] });
return el('div', {
className: 'field switch-field',
children: [text, button],
}) as HTMLDivElement;
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { shelfOrder, shelfSortKey, sortBooks } from '../src/ui/shelf-order.ts';
import type { Book } from '../src/api/types.ts';
/**
* Shelf ordering.
*
* The rule has to match the server's, because the client sorts its own cache and
* the server sorts the real list. A mismatch shows up as a library that reorders
* itself when a phone goes offline — which reads as the reader's setting having
* been ignored.
*/
function book(partial: Partial<Book> & { id: string }): Book {
return {
title: '',
author: '',
publisher: '',
language: '',
isbn: '',
description: '',
series: '',
seriesIndex: null,
tags: [],
pubdate: '',
format: 'epub',
coverUrl: null,
fileSize: 0,
pageCount: null,
source: '',
manualFields: [],
updatedAt: 0,
...partial,
};
}
describe('shelfOrder', () => {
it('ascends for name keys and descends for dates', () => {
// A reader who asks for "by title" means A→Z; "recent" means newest first.
expect(shelfOrder('title')).toBe('asc');
expect(shelfOrder('author')).toBe('asc');
expect(shelfOrder('updated')).toBe('desc');
expect(shelfOrder('added')).toBe('desc');
});
});
describe('sortBooks', () => {
it('sorts Chinese chapter titles numerically', () => {
const books = [
book({ id: 'a', title: '第10章' }),
book({ id: 'b', title: '第9章' }),
book({ id: 'c', title: '第2章' }),
];
// Plain string comparison would give 第10章 < 第2章 < 第9章, which is the bug
// `numeric: true` exists to prevent.
expect(sortBooks(books, 'title').map((entry) => entry.title)).toEqual(['第2章', '第9章', '第10章']);
});
it('puts the newest first for a date sort', () => {
const books = [
book({ id: 'old', title: 'Old', updatedAt: 100 }),
book({ id: 'new', title: 'New', updatedAt: 300 }),
book({ id: 'mid', title: 'Mid', updatedAt: 200 }),
];
expect(sortBooks(books, 'updated').map((entry) => entry.id)).toEqual(['new', 'mid', 'old']);
});
it('uses addedAt for the acquisition sort, not the file mtime', () => {
const books = [
// A book acquired yesterday whose file was replaced today.
book({ id: 'replaced', addedAt: 100, updatedAt: 900 }),
book({ id: 'acquired', addedAt: 500, updatedAt: 500 }),
];
// "最近入库" asks about acquisition, "最近更新" asks about the file. Collapsing
// them would answer neither question.
expect(sortBooks(books, 'added').map((entry) => entry.id)).toEqual(['acquired', 'replaced']);
expect(sortBooks(books, 'updated').map((entry) => entry.id)).toEqual(['replaced', 'acquired']);
});
it('falls back to updatedAt when the server does not send addedAt', () => {
const older = book({ id: 'older', updatedAt: 100 });
const newer = book({ id: 'newer', updatedAt: 200 });
// An older server omits the field; the shelf must still order, not throw.
expect(sortBooks([older, newer], 'added').map((entry) => entry.id)).toEqual(['newer', 'older']);
});
it('is stable for books that tie, so the grid does not shuffle between renders', () => {
const books = [
book({ id: 'z', title: 'Same', updatedAt: 100 }),
book({ id: 'a', title: 'Same', updatedAt: 100 }),
];
const first = sortBooks(books, 'title').map((entry) => entry.id);
const second = sortBooks([...books].reverse(), 'title').map((entry) => entry.id);
expect(first).toEqual(second);
expect(first).toEqual(['a', 'z']);
});
it('does not mutate the array it is given', () => {
const books = [book({ id: 'b', title: 'B' }), book({ id: 'a', title: 'A' })];
sortBooks(books, 'title');
expect(books.map((entry) => entry.id)).toEqual(['b', 'a']);
});
it('has a key for every sort', () => {
const sample = book({ id: 'x', title: 'T', author: 'A', addedAt: 5, updatedAt: 7 });
for (const sort of ['title', 'author', 'added', 'updated'] as const) {
expect(shelfSortKey(sample, sort)).toBeTruthy();
}
});
});
+296
View File
@@ -0,0 +1,296 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { createSpeechEngine, speechAvailability, SPEECH_ENGINE_LABELS } from '../src/render/speech.ts';
import type { NativeSpeechBridge } from '../src/android-bridge.ts';
import type { TtsSnapshot } from '../src/render/tts.ts';
/**
* Engine selection and the native engine's queue.
*
* The selection order is what makes this testable rather than a `if` chain in the
* screen: given what a host has, which engine speaks? Getting that wrong shows up
* as a reader on an Android phone being read to in English by the WebView engine
* while the OS has a perfectly good Chinese voice installed — the exact bug the
* native engine exists to fix.
*/
function fakeNativeBridge(): {
bridge: NativeSpeechBridge;
events: Array<{ type: string; message?: string }>;
spoken: string[];
/** Fires an event for the utterance the bridge was last asked to speak. */
fire(type: string, message?: string): void;
/** Fires an event for an utterance the reader has already moved past. */
fireStale(type: string, message?: string): void;
} {
const events: Array<{ type: string; message?: string }> = [];
const spoken: string[] = [];
let handler: ((event: { type: string; id?: string; message?: string }) => void) | null = null;
let lastId = '';
let previousId = '';
const bridge: NativeSpeechBridge = {
available: () => true,
init: () => undefined,
onSpeechEvent: (callback) => {
handler = callback;
},
onVoices: () => undefined,
speak: (text, id) => {
spoken.push(text);
// The id of the utterance before this one, so a test can simulate the
// platform's late `done` for a sentence the reader already left behind.
previousId = lastId;
lastId = id;
},
pause: () => undefined,
stop: () => undefined,
setRate: () => undefined,
setPitch: () => undefined,
setVolume: () => undefined,
setVoice: () => undefined,
shutdown: () => undefined,
};
return {
bridge,
events,
spoken,
fire: (type, message) => {
events.push({ type, ...(message ? { message } : {}) });
handler?.({ type, id: lastId, ...(message ? { message } : {}) });
},
fireStale: (type, message) => {
events.push({ type, stale: true, ...(message ? { message } : {}) } as never);
handler?.({ type, id: previousId, ...(message ? { message } : {}) });
},
};
}
describe('speechAvailability', () => {
it('prefers the native engine on an Android shell', () => {
const availability = speechAvailability({ systemSupported: true, nativeBridge: fakeNativeBridge().bridge, httpConfigured: true });
// Native wins even though the WebView synthesizer is also there, because the
// WebView's voice list is the one that is routinely missing Chinese voices.
expect(availability.preferred).toBe('native');
});
it('falls back to the WebView engine when the shell is too old to have one', () => {
const availability = speechAvailability({ systemSupported: true, nativeBridge: null, httpConfigured: true });
expect(availability.preferred).toBe('system');
});
it('uses HTTP only when there is nothing local, which is the whole point of it', () => {
const availability = speechAvailability({ systemSupported: false, nativeBridge: null, httpConfigured: true });
expect(availability.preferred).toBe('http');
});
it('reports no engine at all rather than pretending one exists', () => {
const availability = speechAvailability({ systemSupported: false, nativeBridge: null, httpConfigured: false });
expect(availability.preferred).toBeNull();
});
it('names every engine it can return', () => {
// A label missing here would render as "undefined" in the picker.
expect(Object.keys(SPEECH_ENGINE_LABELS).sort()).toEqual(['http', 'native', 'system']);
});
});
describe('createSpeechEngine', () => {
it('returns null for an engine this host does not have, rather than a silent stub', () => {
expect(
createSpeechEngine({
kind: 'native',
baseUrl: '',
accessToken: () => null,
nativeBridge: null,
}),
).toBeNull();
});
it('drives the native queue from the done event, not from a timer', async () => {
const native = fakeNativeBridge();
const engine = createSpeechEngine({
kind: 'native',
baseUrl: '',
accessToken: () => null,
nativeBridge: native.bridge,
});
expect(engine).not.toBeNull();
const snapshots: TtsSnapshot[] = [];
engine!.onState = (snapshot) => snapshots.push(snapshot);
engine!.loadQueue = async () => ({
chunks: [
{ text: '一。', node: null, start: 0, blockIndex: 0 },
{ text: '二。', node: null, start: 2, blockIndex: 0 },
],
startIndex: 0,
});
await engine!.play(0);
expect(native.spoken).toEqual(['一。']);
// No second sentence until the platform says the first is done. A timer-driven
// queue would speak two at once on a slow engine.
native.fire('done');
expect(native.spoken).toEqual(['一。', '二。']);
});
it('ignores a late done event from a sentence the reader moved past', async () => {
const native = fakeNativeBridge();
const engine = createSpeechEngine({
kind: 'native',
baseUrl: '',
accessToken: () => null,
nativeBridge: native.bridge,
})!;
engine.loadQueue = async () => ({
chunks: [
{ text: '一。', node: null, start: 0, blockIndex: 0 },
{ text: '二。', node: null, start: 2, blockIndex: 0 },
{ text: '三。', node: null, start: 4, blockIndex: 0 },
],
startIndex: 0,
});
await engine.play(0);
engine.next();
expect(native.spoken).toEqual(['一。', '二。']);
// A late `done` for the *first* sentence must not advance the cursor again,
// which would skip 三。 The id is what makes that decidable; without it the
// engine cannot tell "the sentence I am on finished" from "the sentence the
// reader left behind finished".
native.fireStale('done');
expect(native.spoken).toEqual(['一。', '二。']);
});
it('never reports the same sentence twice, even when the engine re-emits state', async () => {
const native = fakeNativeBridge();
const engine = createSpeechEngine({
kind: 'native',
baseUrl: '',
accessToken: () => null,
nativeBridge: native.bridge,
})!;
const highlighted: string[] = [];
engine.onChunk = (chunk) => highlighted.push(chunk.text);
engine.loadQueue = async () => ({
chunks: [
{ text: '一。', node: null, start: 0, blockIndex: 0 },
{ text: '二。', node: null, start: 2, blockIndex: 0 },
],
startIndex: 0,
});
await engine.play(0);
// `onState` fires on every emit — a volume change, a pause, a rate change —
// and the highlight must be driven by the *sentence changing*, not by the
// state event. Otherwise moving a slider repaints the same highlight, and a
// render-heavy highlight is what makes the reading view stutter.
native.fire('done');
engine.setVolume(0.5);
engine.setRate(1.5);
expect(highlighted).toEqual(['一。', '二。']);
});
it('reports a native error as a message rather than going quiet', async () => {
const native = fakeNativeBridge();
const errors: string[] = [];
const engine = createSpeechEngine({
kind: 'native',
baseUrl: '',
accessToken: () => null,
nativeBridge: native.bridge,
onError: (message) => errors.push(message),
})!;
engine.loadQueue = async () => ({
chunks: [{ text: '一。', node: null, start: 0, blockIndex: 0 }],
startIndex: 0,
});
await engine.play(0);
native.fire('error', '语音服务不可用');
expect(errors).toEqual(['语音服务不可用']);
expect(engine.active).toBe(false);
});
});
describe('the HTTP engine behind the shared interface', () => {
/**
* The adapter's job is to keep the engine's own quirks out of the screen layer:
* it maps an engine *fragment* index back to the sentence the reader can see,
* and it reports that sentence for every step the engine takes.
*
* `fetch` and `Audio` are stubbed here rather than in `tts-http.test.ts`
* because what is under test is the adapter, not the transport.
*/
class StubAudio {
src = '';
volume = 1;
preload = '';
playbackRate = 1;
private handlers = new Map<string, () => void>();
addEventListener(type: string, handler: () => void): void {
this.handlers.set(type, handler);
}
play(): Promise<void> {
return Promise.resolve();
}
pause(): void {}
removeAttribute(): void {}
load(): void {}
fire(type: string): void {
this.handlers.get(type)?.();
}
}
it('reports the sentence a fragment belongs to, not the fragment', async () => {
const audios: StubAudio[] = [];
const engine = createSpeechEngine({
kind: 'http',
baseUrl: 'http://nas.local:8080',
accessToken: () => 'tok',
nativeBridge: null,
})!;
// The screen layer hands over its queue through `loadQueue`, and the adapter
// reads it back to resolve an index — the two are the same list by design.
const queue = [
{ text: '前半', node: null, start: 0, blockIndex: 0 },
{ text: '后半', node: null, start: 2, blockIndex: 0 },
];
engine.loadQueue = async () => ({ chunks: queue, startIndex: 0 });
(engine as unknown as { chunkSource: () => typeof queue }).chunkSource = () => queue;
const highlighted: string[] = [];
engine.onChunk = (chunk) => highlighted.push(chunk.text);
// The audio element is created inside the engine; the stub stands in for it.
const originalAudio = globalThis.Audio;
(globalThis as { Audio: unknown }).Audio = function Stub() {
const audio = new StubAudio();
audios.push(audio);
return audio;
};
try {
await engine.play(0);
expect(highlighted).toEqual(['前半']);
audios[0]!.fire('ended');
expect(highlighted).toEqual(['前半', '后半']);
} finally {
(globalThis as { Audio: unknown }).Audio = originalAudio;
engine.dispose();
}
});
it('hides the pitch row rather than shipping a slider that does nothing', () => {
const engine = createSpeechEngine({
kind: 'http',
baseUrl: 'http://nas.local:8080',
accessToken: () => 'tok',
nativeBridge: null,
})!;
// `setPitch` exists so the interface is uniform, but it is a no-op: synthesised
// audio's only pitch control is its playback rate. The settings panel hides the
// row for this engine, and this asserts the method does not pretend otherwise.
expect(() => engine.setPitch(1.8)).not.toThrow();
expect(engine.snapshot.pitch).toBe(1);
});
});
+213
View File
@@ -0,0 +1,213 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { HttpTtsEngine } from '../src/render/tts-http.ts';
/**
* The HTTP engine's queue and its failure modes.
*
* Two things are worth testing here and both are the reason the engine exists at
* all: that the queue advances on the `<audio>` element's own `ended` event rather
* than on a timer (a timer drifts and overlaps), and that a *misconfigured* server
* is reported rather than played as silence. The second is the failure a reader
* cannot diagnose — an `<audio>` element that loads a web page plays nothing and
* fires no error.
*/
/**
* A fake `<audio>` that fires nothing until the test says so.
*
* `play()` resolving is not the same as playback starting, and the engine must
* not assume otherwise: the queue has to advance on `ended`.
*/
class FakeAudio {
src = '';
volume = 1;
preload = '';
playbackRate = 1;
paused = true;
playCalls = 0;
pauseCalls = 0;
private handlers = new Map<string, () => void>();
addEventListener(type: string, handler: () => void): void {
this.handlers.set(type, handler);
}
play(): Promise<void> {
this.playCalls += 1;
this.paused = false;
return Promise.resolve();
}
pause(): void {
this.pauseCalls += 1;
this.paused = true;
}
removeAttribute(_name: string): void {
this.src = '';
}
load(): void {
// Releasing the source is what the engine does when it is done with an
// element; nothing to simulate beyond accepting the call.
}
/** Fires a registered event, as the media element would. */
fire(type: string): void {
this.handlers.get(type)?.();
}
}
function makeEngine(options: {
chunks: string[][];
baseUrl?: string;
token?: string | null;
playFails?: boolean;
}): { engine: HttpTtsEngine; audios: FakeAudio[]; spoken: string[]; errors: string[] } {
const audios: FakeAudio[] = [];
const spoken: string[] = [];
const errors: string[] = [];
let round = 0;
const engine = new HttpTtsEngine({
baseUrl: options.baseUrl ?? 'http://nas.local:8080',
accessToken: () => options.token ?? null,
createAudio: () => {
const audio = new FakeAudio();
audios.push(audio);
// The engine builds a *fresh* element per sentence, so the last one created
// is the one in flight; recording them lets a test fire its events.
if (options.playFails) {
audio.play = () => Promise.reject(new Error('blocked'));
}
return audio as unknown as HTMLAudioElement;
},
setTimeout: () => 0 as unknown as ReturnType<typeof setTimeout>,
clearTimeout: () => undefined,
onError: (message) => errors.push(message),
});
engine.setQueueLoader(async () => {
const texts = options.chunks[Math.min(round, options.chunks.length - 1)] ?? [];
round += 1;
return { chunks: texts.map((text) => ({ text })), startIndex: 0 };
});
engine.onState((snapshot) => {
if (snapshot.chunk) spoken.push(snapshot.chunk);
});
return { engine, audios, spoken, errors };
}
describe('HttpTtsEngine', () => {
it('asks the server one sentence at a time, with the token in the query', async () => {
const { engine, audios } = makeEngine({ chunks: [['第一句。', '第二句。']], token: 'tok-123' });
await engine.play(0);
expect(audios).toHaveLength(1);
// The token rides in the query because an `<audio src>` cannot carry a header
// — see the module header, and the server's `queryTokenAllowed`.
expect(audios[0]!.src).toContain('/api/v1/tts?');
expect(audios[0]!.src).toContain('access_token=tok-123');
expect(audios[0]!.src).toContain(encodeURIComponent('第一句。'));
});
it('advances only when the audio element reports it finished', async () => {
const { engine, audios } = makeEngine({ chunks: [['一。', '二。', '三。']] });
await engine.play(0);
expect(audios).toHaveLength(1);
// Nothing has ended yet, so no second element may exist. A timer-driven queue
// is what this asserts against.
audios[0]!.fire('ended');
expect(audios).toHaveLength(2);
audios[1]!.fire('ended');
expect(audios).toHaveLength(3);
expect(audios[2]!.src).toContain(encodeURIComponent('三。'));
});
it('does not advance when a stale element reports it finished', async () => {
const { engine, audios } = makeEngine({ chunks: [['一。', '二。', '三。']] });
await engine.play(0);
// The reader pressed "next", which starts a new generation. The old element's
// `ended` arriving afterwards must be ignored, or the queue would skip a
// sentence every time the button is used.
engine.next();
expect(audios).toHaveLength(2);
audios[0]!.fire('ended');
expect(audios).toHaveLength(2);
});
it('stops speaking when the reader stops, and releases the element', async () => {
const { engine, audios } = makeEngine({ chunks: [['一。', '二。']] });
await engine.play(0);
engine.stop();
expect(engine.active).toBe(false);
expect(audios[0]!.pauseCalls).toBeGreaterThan(0);
// Releasing matters: an element left with a `src` keeps a network buffer alive,
// and one per sentence would leak a chapter's worth of them.
expect(audios[0]!.src).toBe('');
});
it('reports a blocked autoplay instead of a bar that says "playing"', async () => {
const { engine, errors } = makeEngine({ chunks: [['一。']], playFails: true });
await engine.play(0);
// The rejection is asynchronous; a microtask turn is enough for the catch.
await Promise.resolve();
await Promise.resolve();
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('播放');
});
it('applies a rate change to the audio already in flight', async () => {
const { engine, audios } = makeEngine({ chunks: [['一。', '二。']] });
await engine.play(0);
engine.setRate(1.5);
// Unlike the system engine, this one does not have to restart the sentence:
// `playbackRate` is a property of the element. That difference is why the two
// engines are separate implementations rather than one with a flag.
expect(audios[0]!.playbackRate).toBe(1.5);
});
it('asks for the next chapter when the queue runs out, when the reader allowed it', async () => {
const { engine, audios } = makeEngine({ chunks: [['一。'], ['二。']] });
await engine.play(0);
audios[0]!.fire('ended');
// The queue ran out, so the loader is consulted again — which is the same
// handshake the system engine uses, and the place a chapter transition happens.
await Promise.resolve();
await Promise.resolve();
expect(audios).toHaveLength(2);
expect(audios[1]!.src).toContain(encodeURIComponent('二。'));
});
it('will not build a URL without a server, and says so rather than failing silently', async () => {
const { engine, errors } = makeEngine({ chunks: [['一。']], baseUrl: '' });
await engine.play(0);
expect(errors.length).toBeGreaterThan(0);
expect(engine.active).toBe(false);
});
it('probes the server and answers null when it has no engine', async () => {
const engine = new HttpTtsEngine({ baseUrl: 'http://nas.local:8080', accessToken: () => null });
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ http: false, formats: [], maxLength: 800, voices: [] }), { status: 200 }),
);
expect(await engine.probe()).toBeNull();
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ http: true, formats: ['audio/mpeg'], maxLength: 800, voices: [{ id: 'v1', name: '晓晓', lang: 'zh-CN' }] }),
{ status: 200 },
),
);
const capabilities = await engine.probe();
expect(capabilities?.http).toBe(true);
expect(capabilities?.voices).toHaveLength(1);
fetchSpy.mockRestore();
});
});