mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 14:31:11 +08:00
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:
36 files changed
+4394
-101
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 | 服务端错误 |
|
||||
|
||||
## 客户端应当遵守的约定
|
||||
|
||||
@@ -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 节点**上,绕过这个方法的翻页会让引擎对着一个已经脱离文档的节点
|
||||
继续朗读——听起来就像客户端在自言自语。
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
Vendored
+86
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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('连不上服务端');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user