mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
feat(web): 丰富阅读界面设置并支持系统 TTS 朗读
This commit is contained in:
11 files changed
+2279
-29
No files matched your search
@@ -79,6 +79,58 @@ volumes:
|
||||
|
||||
---
|
||||
|
||||
## 阅读界面
|
||||
|
||||
界面设置按**设备**保存,不跟着账号同步:字号和主题是「手里这块屏幕」的属性,
|
||||
把手机的字号推到平板上比不同步更糟。阅读进度和笔记才跟账号走。
|
||||
|
||||
### 排版
|
||||
|
||||
| 设置 | 说明 |
|
||||
| --- | --- |
|
||||
| 翻页方式 | 滚动(保留作者连续排版)/ 翻页(CSS 多列分页,一页一屏) |
|
||||
| 字号 | 0.8×–2.2×,只改基准字号 |
|
||||
| 行距 | 默认「原书」。选具体值才覆盖,否则一行都不动 |
|
||||
| 页边距 | 0–4rem,控制每行字数 |
|
||||
| 对齐 | 默认「原书」,可强制左对齐 / 两端对齐 |
|
||||
| 字体 | 默认「原书」。可选系统黑体 / 宋体 / 楷体 / 苹方,作为字体栈的**前缀**而不是替换 |
|
||||
| 主题 | 白 / 米黄 / 夜间 |
|
||||
| 亮度 | 35%–100%,叠加式调暗,文字与纸色的对比关系不变 |
|
||||
| 图片适配 | 仅固定版式(漫画、PDF):完整显示 / 适宽 |
|
||||
| 翻页方向 | 仅固定版式:左→右 / 右→左(日漫) |
|
||||
| TXT 编码 | 仅 TXT:自动 / UTF-8 / GB18030 / Big5 / UTF-16LE |
|
||||
|
||||
一条原则贯穿全部:**每个排版开关的默认值都是「不动」**,不是「我们的默认值」。
|
||||
「原书」不是营销话术而是实现——行距的默认值是 `inherit`,页边距和字号挂在容器的
|
||||
继承变量上,所以一本按章节变行距的书不会被压平,一本给段落写死 `font-size: 14px`
|
||||
的书也不会被字号滑块踩掉。这与「所有书看起来一致」的 Readium 路线正好相反。
|
||||
|
||||
### 翻页
|
||||
|
||||
- **点击区域**:中间呼出/隐藏工具栏,左右翻页;可切换「左进右退」适配左右手。
|
||||
- **翻页动画**:滑动 / 淡入 / 无。系统开启「减少动态效果」时自动不播。
|
||||
- **硬件翻页键**:`←` / `→`、`PageUp` / `PageDown`,空格播放/暂停朗读。
|
||||
Android 外壳把音量键映射成方向键,所以音量键翻页、蓝牙翻页器都走同一条路。
|
||||
|
||||
### 朗读(TTS)
|
||||
|
||||
用系统自带的语音合成,不需要联网、不额外占容器的体积,也不把你的书上传到任何服务。
|
||||
|
||||
- **按句切分**:中文按 `。!?;…`,西文按 `. ! ?` 且避开 `Mr.` / `3.14` / `e.g.`。
|
||||
逐句朗读才能做到朗读位置跟随显示高亮,也能让「暂停/继续」和「改语速」都只影响下一句。
|
||||
- **从当前位置开始**:默认从句首读起;若先选中一段文字再按朗读,就从选中处开始。
|
||||
- **句子级控制**:上一句 / 下一句 / 进度条直接跳句。
|
||||
- **语速、音调、音量**:语速 0.5×–2.5×,改动即时生效(不重读整段,只重读当前句)。
|
||||
- **语音可选**:列出系统里所有可用语音(含语言标签),默认「跟随系统」。
|
||||
- **章节播完**:可选「继续下一章」或「停在章末」——后者就是有声书的「一次一章」。
|
||||
- **高亮**:正在朗读的句子用半透明底色标出,并用 `Range` 绘制,**不改写书的 DOM**,
|
||||
所以开始朗读不会让章节重新分页。
|
||||
|
||||
> 浏览器端朗读依赖 `speechSynthesis`(Chrome / Edge / Android WebView 都有)。
|
||||
> 没有该 API 的环境里,设置面板会直接说明,而不是给一个按了没反应的按钮。
|
||||
|
||||
---
|
||||
|
||||
## 书库扫描
|
||||
|
||||
- **增量扫描**:先用 `mtime + size` 快速判断,再用内容哈希确认。绝大多数文件在第一阶段就被跳过。
|
||||
|
||||
@@ -310,3 +310,70 @@ shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做
|
||||
漫画和 PDF 是 `layout: 'fixed'`,每页一个 section,由分页器整屏推进。
|
||||
固定布局的宿主不做滚动容器——在横向分页器里嵌一个滚动区会产生
|
||||
Android 上经典的「页面自己往边上飘」bug。
|
||||
|
||||
---
|
||||
|
||||
## 9. 界面设置与朗读
|
||||
|
||||
### 为什么设置不跟着账号同步
|
||||
|
||||
同一份「设置」里其实住着两类东西:
|
||||
|
||||
| 类别 | 例子 | 真相源 |
|
||||
| --- | --- | --- |
|
||||
| 屏幕与环境的属性 | 字号、主题、亮度、页边距 | 本机 |
|
||||
| 读者的属性 | 阅读位置、笔记、高亮 | 账号 |
|
||||
|
||||
把第一类推到服务端,结果就是把手机上的字号搬到平板上打开——这是所有人都讨厌的行为。
|
||||
所以 `SettingsStore` 存在设备本地(`reader.settings.v1`),同步层完全不知道它存在。
|
||||
|
||||
### 新增的每一个排版开关,默认值都是「不动」
|
||||
|
||||
这条不是风格问题,是 §8 那个承诺的直接推论。「忠于精排」在实现上只有一种写法:
|
||||
**除非不设就会坏,否则不设**。
|
||||
|
||||
所以行距的默认值是字符串 `inherit`,字体和文本对齐同样;它们经 CSS 自定义属性
|
||||
继承进 shadow root,书里写死的值自然胜出。字号改的是容器的基准字号,不是每段的大小,
|
||||
所以 `font-size: 14px` 的段落保持原样,`1.2em` 的段落跟着滑块走。
|
||||
|
||||
页边距是唯一一个「用户值必然生效」的排版项。它不是给书的,是给**行宽**的:
|
||||
一行 40 个汉字在手机上没法读,而约束行宽不能靠改 `p` 的 margin——那正是要避免的编辑。
|
||||
改成给容器加内边距,作者的样式一条都不用动。
|
||||
|
||||
亮度做成了 `mix-blend-mode: multiply` 的覆盖层而不是调色板:换主题应该保留
|
||||
纸与字的对比关系,直接改颜色会把文字一起压暗,那正是「亮度」滑杆最常见的失败。
|
||||
|
||||
### 朗读为什么按句排队
|
||||
|
||||
`speechSynthesis` 本身只有一行 API,难的是决定**下一句说什么**。按句而不是按段:
|
||||
|
||||
- 高亮能跟着声音走。`boundary` 事件(词级位置)在 Android WebView 上不存在,
|
||||
在桌面 Chrome 上也不可靠,所以依赖它做高亮等于在目标平台上不做高亮。
|
||||
- 一章拆成几十句,语速、音调、语音的修改只需重读**当前句**;按段修改则要重读整段。
|
||||
- 章节切换发生在「一句读完」的回调里,是引擎唯一明确告诉我们「这句话说完了」
|
||||
的时刻——除此之外的推进都可能把两句同时说出去。
|
||||
|
||||
切句规则刻意保守:`.` 后面不是空白就不算句末,`Mr.` / `Dr.` / `3.14` / `e.g.`
|
||||
全部被这一条挡住。代价是 `end.Next`(句号后不加空格)只剩一句,这比把 `Mr.`
|
||||
和它的名字切开要小得多。没有标点的长段落按 300 字硬切——Android 的合成器
|
||||
遇到两千字的单条 utterance 会直接沉默。
|
||||
|
||||
### 高亮用 Range 画,不改书的 DOM
|
||||
|
||||
把正在朗读的句子包进 `<span>` 是最直觉的做法,也是最糟的:它改写了出版方的标记,
|
||||
会让作者针对 `p > span` 的样式失效,并且因为行盒度量变化而**重新分页**——
|
||||
读者一按朗读,页面就跳。所以高亮是 flow 里的一个绝对定位覆盖层,
|
||||
位置来自 `Range.getBoundingClientRect()`,文字本身一个字节都没动。
|
||||
|
||||
代价是高亮必须自己跟随滚动、自己处理换行(一个跨行的句子只有一个矩形),
|
||||
这比包 `<span>` 麻烦,但换来的是「按朗读不会动版」。
|
||||
|
||||
### 朗读队列与章节的边界
|
||||
|
||||
引擎只知道一件事:给我一批句子,说我读到哪里了。章节的事由屏幕层负责——
|
||||
队列耗尽时引擎再问一次,屏幕层决定是「取下一章」还是「到此为止」。
|
||||
这个分工让「读到章末停下」成为设置项而不是引擎里的分支。
|
||||
|
||||
屏幕层还有一个必须走的公共路径:所有翻页都经过 `turnPage()`。原因是引擎锚定在
|
||||
**当前章节的 DOM 节点**上,绕过这个方法的翻页会让引擎对着一个已经脱离文档的节点
|
||||
继续朗读——听起来就像客户端在自言自语。
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Turning a page of a book into speech.
|
||||
*
|
||||
* The hard part of TTS in a reader is not the synthesizer — `speechSynthesis` is
|
||||
* one API call. It is deciding **what to say next**, because everything a reader
|
||||
* wants to be able to do mid-sentence depends on it:
|
||||
*
|
||||
* - highlight the sentence being spoken (so the highlight must be addressable),
|
||||
* - tap a paragraph and hear it from there (so a chunk must map back to a block),
|
||||
* - pause and resume without repeating a whole paragraph,
|
||||
* - switch to another voice mid-book without losing the place.
|
||||
*
|
||||
* So the unit is a *sentence*, not a paragraph and not the chapter: paragraphs in
|
||||
* a Chinese novel can run to a thousand characters, and `speechSynthesis` given
|
||||
* one of those either stutters or is interrupted by any other speech request.
|
||||
* Sentence granularity is what every reading app that feels good uses, and it is
|
||||
* what makes the highlight track the voice instead of jumping block by block.
|
||||
*
|
||||
* ## Splitting rules, and why they are conservative
|
||||
*
|
||||
* Chinese books use 。!?…; Western ones use .!? and are full of `Mr.` and
|
||||
* `3.14`. The rule below never splits on a `.` that follows a single uppercase
|
||||
* letter or a digit, and never merges across a paragraph. Chinese needs no such
|
||||
* care — a full-width stop is unambiguous — which is why the two are handled by
|
||||
* different branches rather than one clever regex.
|
||||
*
|
||||
* ## Where the text comes from
|
||||
*
|
||||
* From the rendered DOM, not from the book's markup. Two reasons: the reader may
|
||||
* have a chapter whose markup nests text in ways that only the layout engine
|
||||
* resolves (a table cell, a footnote aside), and the spoken text must be exactly
|
||||
* the text the reader can see, including anything the format loader generated.
|
||||
* `SpokenChunk.node` therefore points at the *text node* the sentence came from,
|
||||
* which is what makes the highlight possible without wrapping every sentence in
|
||||
* a `<span>` — wrapping would edit the publisher's markup, which this client
|
||||
* refuses to do anywhere else and has no reason to start doing here.
|
||||
*/
|
||||
|
||||
/** Elements whose text is not reading material. */
|
||||
const SKIPPED_TAGS = new Set(['SCRIPT', 'STYLE', 'RT', 'RP', 'CODE', 'PRE']);
|
||||
|
||||
/** Sentence terminators. Kept as two sets because the rules around them differ. */
|
||||
const CJK_TERMINATORS = new Set(['。', '!', '?', ';', '…', '‼', '⁇', '⁈', '⁉']);
|
||||
const LATIN_TERMINATORS = new Set(['.', '!', '?', ';']);
|
||||
|
||||
/** Pairs that may *follow* a terminator and still belong to the same sentence. */
|
||||
const CLOSERS = new Set(['\u201d', '\u2019', '\u300d', '\u300f', '\uff09', ')', '\u3011', '\u300b', ']', '"', "'"]);
|
||||
|
||||
export interface SpokenChunk {
|
||||
/** The sentence, whitespace-collapsed, ready to be handed to the synthesizer. */
|
||||
text: string;
|
||||
/** The text node the sentence starts in, for highlighting. Null when detached. */
|
||||
node: Text | null;
|
||||
/** Offset inside `node` where the sentence starts. */
|
||||
start: number;
|
||||
/** The top-level block the sentence lives in, so the view can scroll to it. */
|
||||
blockIndex: number;
|
||||
}
|
||||
|
||||
export interface CollectOptions {
|
||||
/** Hard ceiling per utterance; a pathological paragraph must still be spoken. */
|
||||
maxChunkLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the chapter's blocks in document order and produces speakable sentences.
|
||||
*
|
||||
* `root` is the element holding the book's own markup (inside the shadow root).
|
||||
* `blocksOf` maps a node to the index of the top-level block containing it — it is
|
||||
* injected rather than computed here because the paginator owns that notion, and
|
||||
* a sentence must be addressable by the same index a reading position uses.
|
||||
*/
|
||||
export function collectSpokenChunks(
|
||||
root: ParentNode,
|
||||
blocksOf: (node: Node) => number,
|
||||
options: CollectOptions = {},
|
||||
): SpokenChunk[] {
|
||||
const max = options.maxChunkLength ?? 300;
|
||||
const chunks: SpokenChunk[] = [];
|
||||
const walker = root.ownerDocument?.createTreeWalker
|
||||
? root.ownerDocument.createTreeWalker(root as Node, 4 /* SHOW_TEXT */)
|
||||
: null;
|
||||
if (!walker) return chunks;
|
||||
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
const text = node as Text;
|
||||
if (!isSkipped(text)) {
|
||||
const blockIndex = blocksOf(text);
|
||||
for (const piece of splitSentence(text.data, max)) {
|
||||
const value = piece.text.trim();
|
||||
if (!value) continue;
|
||||
// A chunk needs at least one letter or ideograph; a lone "——" or "123"
|
||||
// produces no useful audio and would make the highlight look broken.
|
||||
if (!/[\p{L}\p{N}]/u.test(value)) continue;
|
||||
chunks.push({ text: value, node: text, start: piece.start, blockIndex });
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits one text node into sentence pieces.
|
||||
*
|
||||
* Exported because it is the only part with a decision in it, and it is tested
|
||||
* directly instead of through a DOM.
|
||||
*/
|
||||
export function splitSentence(text: string, maxLength = 300): Array<{ text: string; start: number }> {
|
||||
const pieces: Array<{ text: string; start: number }> = [];
|
||||
let start = 0;
|
||||
let index = 0;
|
||||
|
||||
const flush = (end: number): void => {
|
||||
if (end <= start) return;
|
||||
pieces.push({ text: text.slice(start, end), start });
|
||||
start = end;
|
||||
};
|
||||
|
||||
while (index < text.length) {
|
||||
const char = text[index]!;
|
||||
if (!CJK_TERMINATORS.has(char) && !LATIN_TERMINATORS.has(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A Latin terminator is only a terminator when followed by whitespace or by
|
||||
// the end of the node. That single rule is what protects "Mr. Smith",
|
||||
// "3.14" and "e.g." from being cut into three utterances.
|
||||
if (LATIN_TERMINATORS.has(char) && !(char === '.' ? isSentenceDot(text, index) : true)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (LATIN_TERMINATORS.has(char)) {
|
||||
const next = text[index + 1];
|
||||
if (next !== undefined && !/\s/.test(next) && !CLOSERS.has(next)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let end = index + 1;
|
||||
// Swallow a run of identical terminators ("什么?!") and any closing quote
|
||||
// or bracket, so the sentence ends where a reader would say it ends.
|
||||
while (end < text.length && (CJK_TERMINATORS.has(text[end]!) || LATIN_TERMINATORS.has(text[end]!))) end += 1;
|
||||
while (end < text.length && CLOSERS.has(text[end]!)) end += 1;
|
||||
flush(Math.min(text.length, end));
|
||||
index = end;
|
||||
|
||||
// A "sentence" longer than the ceiling (a paragraph with no punctuation at
|
||||
// all, which Chinese web novels do produce) is cut at the last whitespace
|
||||
// before the ceiling. Speaking a 2000-character utterance is what makes the
|
||||
// synthesizer go silent on Android, so this is not optional.
|
||||
if (index - start > maxLength) {
|
||||
index = lastBreak(text, start, Math.min(index, start + maxLength));
|
||||
flush(index);
|
||||
}
|
||||
}
|
||||
|
||||
// The same ceiling applies to text that has *no* terminator at all: a run of
|
||||
// text with no punctuation would otherwise leave this loop with one enormous
|
||||
// piece, which is the exact case the ceiling exists for.
|
||||
while (text.length - start > maxLength) {
|
||||
const cut = lastBreak(text, start, start + maxLength);
|
||||
flush(cut);
|
||||
}
|
||||
|
||||
flush(text.length);
|
||||
return pieces.filter((piece) => piece.text.trim().length > 0);
|
||||
}
|
||||
|
||||
/** The best place to cut a too-long sentence: whitespace, or failing that a hard cut. */
|
||||
function lastBreak(text: string, start: number, limit: number): number {
|
||||
for (let i = limit - 1; i > start; i -= 1) {
|
||||
if (/\s/.test(text[i]!)) return i + 1;
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the `.` at `index` really ends a sentence.
|
||||
*
|
||||
* One rule covers both false positives: a stop that is followed by something that
|
||||
* is not whitespace does not end a sentence. "Mr. Smith" has a letter before and a
|
||||
* space after (ambiguous), but "3.14", "e.g." and "U.S." are all immediately
|
||||
* followed by a non-space, so they are never cut. The trade is that a genuine
|
||||
* "end.Next" with no space survives as one sentence — which is a much smaller
|
||||
* error than cutting "Mr." off from its name.
|
||||
*/
|
||||
function isSentenceDot(text: string, index: number): boolean {
|
||||
const previous = text[index - 1];
|
||||
// "3.14" — a digit before a stop is a number, not a sentence.
|
||||
if (previous !== undefined && /\d/.test(previous)) return false;
|
||||
// "Dr." / "Mr." / "St." — a known abbreviation before a stop.
|
||||
if (/\b(?:Mr|Mrs|Ms|Dr|St|Jr|Sr|Prof|Rev|vs|etc|No|Fig|Ch|Vol|p|pp|al)$/.test(text.slice(Math.max(0, index - 8), index))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Skips nodes inside elements that are never read aloud. */
|
||||
function isSkipped(node: Text): boolean {
|
||||
let parent: Node | null = node.parentNode;
|
||||
while (parent) {
|
||||
const name = (parent as Element).tagName;
|
||||
if (name && SKIPPED_TAGS.has(name)) return true;
|
||||
parent = parent.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chunk list, navigated by sentence rather than by index arithmetic.
|
||||
*
|
||||
* The reader keeps a cursor into this; `next`/`previous` skip over chunks whose
|
||||
* text node has been detached by a chapter change, which is what makes the
|
||||
* toolbar's buttons safe to press at any moment.
|
||||
*/
|
||||
export class ChunkCursor {
|
||||
private chunks: SpokenChunk[] = [];
|
||||
private cursor = -1;
|
||||
|
||||
get all(): readonly SpokenChunk[] {
|
||||
return this.chunks;
|
||||
}
|
||||
|
||||
get index(): number {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
get current(): SpokenChunk | null {
|
||||
return this.chunks[this.cursor] ?? null;
|
||||
}
|
||||
|
||||
reset(chunks: SpokenChunk[], index = 0): void {
|
||||
this.chunks = chunks;
|
||||
this.cursor = chunks.length === 0 ? -1 : Math.max(0, Math.min(chunks.length - 1, index));
|
||||
}
|
||||
|
||||
next(): SpokenChunk | null {
|
||||
if (this.cursor + 1 >= this.chunks.length) return null;
|
||||
this.cursor += 1;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
previous(): SpokenChunk | null {
|
||||
if (this.cursor <= 0) return null;
|
||||
this.cursor -= 1;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** The first chunk at or after `chunk`, used when resuming after a jump. */
|
||||
seekTo(chunk: SpokenChunk | null): boolean {
|
||||
if (!chunk) return false;
|
||||
const index = this.chunks.indexOf(chunk);
|
||||
if (index === -1) return false;
|
||||
this.cursor = index;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Read-aloud, on the Web Speech API.
|
||||
*
|
||||
* A self-hosted reader cannot ship a TTS engine: bundling a neural model would
|
||||
* multiply the container size, and a server-side synthesizer would mean streaming
|
||||
* audio for every sentence, on the reader's own CPU budget, over their own
|
||||
* network. `speechSynthesis` is already on the device, is free, works offline,
|
||||
* and is the same engine the OS screen-reader uses — which is exactly the quality
|
||||
* the reader already tolerates.
|
||||
*
|
||||
* ## Why the queue is driven sentence by sentence instead of `speak(chapter)`
|
||||
*
|
||||
* Three things are impossible otherwise:
|
||||
*
|
||||
* 1. **Highlighting.** The `boundary` event that would give word positions is
|
||||
* not implemented on Android's WebView or reliably on Chrome. Advancing one
|
||||
* utterance at a time makes the highlight exact at sentence granularity, with
|
||||
* no dependence on an event that may never fire.
|
||||
* 2. **Chapter transitions.** The utterance callback is the only moment the
|
||||
* engine tells us a sentence *finished* rather than was cancelled, so it is
|
||||
* the only safe place to fetch the next chapter.
|
||||
* 3. **Rate changes.** Changing `rate` mid-utterance either does nothing or
|
||||
* restarts the sentence on most implementations. Splitting the queue means a
|
||||
* rate change takes effect on the next sentence, which is what a reader
|
||||
* expects and never repeats text.
|
||||
*
|
||||
* ## Voices
|
||||
*
|
||||
* `getVoices()` is empty on first call in Chrome until the `voiceschanged` event
|
||||
* fires, which is the single most common reason a "voice picker" ships empty. The
|
||||
* engine therefore declares voices as loadable state and re-emits when the list
|
||||
* arrives, rather than reading it once at construction.
|
||||
*/
|
||||
|
||||
import type { SpokenChunk } from './tts-text.ts';
|
||||
|
||||
export type TtsState = 'idle' | 'playing' | 'paused' | 'unsupported';
|
||||
|
||||
export interface TtsVoice {
|
||||
/** Stable key: `voiceURI` when present, else `name|lang`. */
|
||||
id: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
export interface TtsSnapshot {
|
||||
state: TtsState;
|
||||
/** Text of the sentence being spoken. */
|
||||
chunk: string;
|
||||
/** Where the current sentence sits in the queue. */
|
||||
index: number;
|
||||
total: number;
|
||||
rate: number;
|
||||
pitch: number;
|
||||
volume: number;
|
||||
voiceId: string;
|
||||
voices: TtsVoice[];
|
||||
/** Human-readable reason the engine is idle when it was asked to play. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface TtsCallbacks {
|
||||
/**
|
||||
* Provide the queue. Called on play, and again when the queue runs out, so a
|
||||
* chapter transition is a normal part of the loop instead of a special case.
|
||||
* Returning an empty array means "nothing more to read".
|
||||
*/
|
||||
loadQueue(fromIndex: number): Promise<{ chunks: SpokenChunk[]; startIndex: number }>;
|
||||
/** A sentence started: highlight it and keep it on screen. */
|
||||
onChunk(chunk: SpokenChunk, index: number): void;
|
||||
onState(snapshot: TtsSnapshot): void;
|
||||
}
|
||||
|
||||
export interface TtsOptions extends TtsCallbacks {
|
||||
/** Injected for tests; defaults to `window.speechSynthesis`. */
|
||||
synthesis?: SpeechSynthesis | null;
|
||||
utteranceFactory?: () => SpeechSynthesisUtterance;
|
||||
/** Injected clock so tests do not wait for real speech. */
|
||||
setTimeout?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimeout?: (handle: ReturnType<typeof setTimeout>) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_RATE = 1;
|
||||
const DEFAULT_PITCH = 1;
|
||||
const DEFAULT_VOLUME = 1;
|
||||
|
||||
export class TtsEngine {
|
||||
private readonly options: TtsOptions;
|
||||
private readonly voices: TtsVoice[] = [];
|
||||
private queue: SpokenChunk[] = [];
|
||||
private cursor = -1;
|
||||
private status: TtsState = 'idle';
|
||||
private error = '';
|
||||
private voiceId = '';
|
||||
private rate = DEFAULT_RATE;
|
||||
private pitch = DEFAULT_PITCH;
|
||||
private volume = DEFAULT_VOLUME;
|
||||
/** Generation counter: bumped on every stop, so stale callbacks are ignored. */
|
||||
private generation = 0;
|
||||
/** True between `speak()` and its terminal event, so a stuck engine is visible. */
|
||||
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.rate = DEFAULT_RATE;
|
||||
this.loadVoices();
|
||||
// Chrome fires this after the voice list is populated, which on a cold start
|
||||
// is *after* the first `getVoices()` returned nothing.
|
||||
this.synthesis?.addEventListener?.('voiceschanged', () => this.loadVoices());
|
||||
this.emit();
|
||||
}
|
||||
|
||||
static isSupported(): boolean {
|
||||
return typeof window !== 'undefined' && 'speechSynthesis' in window && typeof window.SpeechSynthesisUtterance === 'function';
|
||||
}
|
||||
|
||||
private get synthesis(): SpeechSynthesis | null {
|
||||
if (this.options.synthesis !== undefined) return this.options.synthesis;
|
||||
if (typeof window === 'undefined' || !('speechSynthesis' in window)) return null;
|
||||
return window.speechSynthesis;
|
||||
}
|
||||
|
||||
get snapshot(): TtsSnapshot {
|
||||
return {
|
||||
state: this.status,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the engine is holding a queue, whatever its playback state. */
|
||||
get active(): boolean {
|
||||
return this.status === 'playing' || this.status === 'paused';
|
||||
}
|
||||
|
||||
async play(from = 0): Promise<void> {
|
||||
const synthesis = this.synthesis;
|
||||
if (!synthesis) {
|
||||
this.status = 'unsupported';
|
||||
this.error = '当前浏览器不支持朗读';
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
if (this.status === 'paused') {
|
||||
this.resume();
|
||||
return;
|
||||
}
|
||||
this.generation += 1;
|
||||
synthesis.cancel();
|
||||
const loaded = await this.options.loadQueue(from);
|
||||
if (loaded.chunks.length === 0) {
|
||||
this.status = 'idle';
|
||||
this.error = '这一章没有可朗读的文字';
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
this.queue = loaded.chunks;
|
||||
this.cursor = clamp(loaded.startIndex, 0, loaded.chunks.length - 1) - 1;
|
||||
this.error = '';
|
||||
this.status = 'playing';
|
||||
this.speakNext();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
const synthesis = this.synthesis;
|
||||
if (!synthesis || this.status !== 'playing') return;
|
||||
this.status = 'paused';
|
||||
// Android's WebView pauses an utterance reliably; Chrome on the desktop
|
||||
// sometimes keeps going, which is why the cursor is not advanced here and
|
||||
// `speakNext` is idempotent for the current sentence.
|
||||
synthesis.pause();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
const synthesis = this.synthesis;
|
||||
if (!synthesis || this.status !== 'paused') return;
|
||||
this.status = 'playing';
|
||||
if (synthesis.paused) synthesis.resume();
|
||||
else this.speakNext();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.generation += 1;
|
||||
this.clearWatchdog();
|
||||
this.speaking = false;
|
||||
this.synthesis?.cancel();
|
||||
this.queue = [];
|
||||
this.cursor = -1;
|
||||
this.status = 'idle';
|
||||
this.error = '';
|
||||
this.emit();
|
||||
}
|
||||
|
||||
async next(): Promise<void> {
|
||||
if (this.queue.length === 0) return;
|
||||
await this.jump(this.cursor + 1);
|
||||
}
|
||||
|
||||
async previous(): Promise<void> {
|
||||
if (this.queue.length === 0) return;
|
||||
// A press during playback restarts the *current* sentence; a press from a
|
||||
// paused or idle state steps back one, because then the reader is looking at
|
||||
// the sentence list rather than listening. This is what every phone player
|
||||
// does, and getting it wrong makes re-reading a sentence impossible.
|
||||
const index = this.status === 'playing' ? this.cursor : this.cursor - 1;
|
||||
await this.jump(Math.max(0, index));
|
||||
}
|
||||
|
||||
/** Jump to a sentence by index, continuing playback. */
|
||||
async jump(index: number): Promise<void> {
|
||||
if (this.queue.length === 0) return;
|
||||
this.generation += 1;
|
||||
this.clearWatchdog();
|
||||
this.speaking = false;
|
||||
this.synthesis?.cancel();
|
||||
this.cursor = clamp(index, 0, this.queue.length - 1) - 1;
|
||||
this.status = 'playing';
|
||||
this.speakNext();
|
||||
}
|
||||
|
||||
setRate(rate: number): void {
|
||||
this.rate = clamp(rate, 0.5, 3);
|
||||
this.restartCurrentSentence();
|
||||
}
|
||||
|
||||
setPitch(pitch: number): void {
|
||||
this.pitch = clamp(pitch, 0.5, 2);
|
||||
this.restartCurrentSentence();
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
this.volume = clamp(volume, 0, 1);
|
||||
this.restartCurrentSentence();
|
||||
}
|
||||
|
||||
setVoice(voiceId: string): void {
|
||||
this.voiceId = voiceId;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.generation += 1;
|
||||
this.clearWatchdog();
|
||||
this.synthesis?.cancel();
|
||||
this.queue = [];
|
||||
this.cursor = -1;
|
||||
this.status = 'idle';
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
/**
|
||||
* Speaks the sentence after the cursor, or finishes.
|
||||
*
|
||||
* Everything terminal funnels through here: the utterance's `onend`, the
|
||||
* watchdog, and the queue-exhausted path. That is deliberate — the one thing
|
||||
* that must not happen is two of them advancing the cursor.
|
||||
*/
|
||||
private speakNext(): void {
|
||||
const synthesis = this.synthesis;
|
||||
if (!synthesis) return;
|
||||
if (this.status !== 'playing') return;
|
||||
|
||||
const nextIndex = this.cursor + 1;
|
||||
const chunk = this.queue[nextIndex];
|
||||
if (!chunk) {
|
||||
this.finish();
|
||||
return;
|
||||
}
|
||||
|
||||
this.cursor = nextIndex;
|
||||
this.options.onChunk(chunk, nextIndex);
|
||||
this.emit();
|
||||
|
||||
const utterance = this.options.utteranceFactory
|
||||
? this.options.utteranceFactory()
|
||||
: new SpeechSynthesisUtterance(chunk.text);
|
||||
utterance.text = chunk.text;
|
||||
utterance.rate = this.rate;
|
||||
utterance.pitch = this.pitch;
|
||||
utterance.volume = this.volume;
|
||||
const voice = this.voices.find((candidate) => candidate.id === this.voiceId);
|
||||
const raw = voice ? findRawVoice(synthesis, voice) : null;
|
||||
if (raw) {
|
||||
utterance.voice = raw;
|
||||
utterance.lang = raw.lang;
|
||||
}
|
||||
|
||||
const generation = this.generation;
|
||||
this.speaking = true;
|
||||
utterance.onend = () => {
|
||||
if (generation !== this.generation) return;
|
||||
this.speaking = false;
|
||||
this.clearWatchdog();
|
||||
this.speakNext();
|
||||
};
|
||||
utterance.onerror = (event) => {
|
||||
if (generation !== this.generation) return;
|
||||
this.speaking = false;
|
||||
this.clearWatchdog();
|
||||
const reason = (event as SpeechSynthesisErrorEvent).error;
|
||||
// `canceled`/`interrupted` are our own cancel() calls coming back; they are
|
||||
// not failures and must not surface as an error message.
|
||||
if (reason === 'canceled' || reason === 'interrupted') return;
|
||||
this.error = `朗读失败:${reason || '未知原因'}`;
|
||||
this.status = 'idle';
|
||||
this.emit();
|
||||
};
|
||||
|
||||
// The engine is asked to stop anything still queued so the new utterance
|
||||
// starts immediately; without this, a chapter transition queues behind the
|
||||
// previous chapter on some Android builds.
|
||||
try {
|
||||
synthesis.speak(utterance);
|
||||
} catch (err) {
|
||||
this.speaking = false;
|
||||
this.error = err instanceof Error ? err.message : '朗读启动失败';
|
||||
this.status = 'idle';
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
|
||||
// A watchdog, not a timeout on speech: it only fires when the engine has
|
||||
// gone silent without telling us, which is a real Android WebView behaviour
|
||||
// after a long screen-off period. The bound is generous because a long
|
||||
// sentence at 0.5× genuinely takes a while.
|
||||
this.clearWatchdog();
|
||||
const budget = Math.max(6000, (chunk.text.length / (this.rate * 4)) * 1000 * 3);
|
||||
const schedule = this.options.setTimeout ?? setTimeout;
|
||||
this.watchdog = schedule(() => {
|
||||
this.watchdog = null;
|
||||
if (generation !== this.generation || !this.speaking) return;
|
||||
this.speaking = false;
|
||||
if (this.status === 'playing') this.speakNext();
|
||||
}, budget);
|
||||
}
|
||||
|
||||
/** No more sentences: the reader controller decides whether to load a chapter. */
|
||||
private finish(): void {
|
||||
const generation = ++this.generation;
|
||||
this.status = 'idle';
|
||||
this.emit();
|
||||
// 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
|
||||
.loadQueue(this.queue.length)
|
||||
.then((loaded) => {
|
||||
if (generation !== this.generation || !loaded.chunks.length) return;
|
||||
this.queue = loaded.chunks;
|
||||
this.cursor = clamp(loaded.startIndex, 0, loaded.chunks.length - 1) - 1;
|
||||
this.status = 'playing';
|
||||
this.speakNext();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a rate/pitch/volume change to the sentence in flight.
|
||||
*
|
||||
* Restarting the current sentence is the only reliable way to change rate on
|
||||
* the engines that ignore it mid-utterance, and restarting a *sentence* is a
|
||||
* small enough repeat to be acceptable; restarting a chapter would not be.
|
||||
*/
|
||||
private restartCurrentSentence(): void {
|
||||
this.emit();
|
||||
if (this.status !== 'playing') return;
|
||||
const index = this.cursor;
|
||||
if (index < 0) return;
|
||||
this.generation += 1;
|
||||
this.clearWatchdog();
|
||||
this.speaking = false;
|
||||
this.synthesis?.cancel();
|
||||
this.cursor = index - 1;
|
||||
// Restart asynchronously: cancel() and speak() in the same task is ignored by
|
||||
// Chrome, which is why this goes through a macrotask.
|
||||
const schedule = this.options.setTimeout ?? setTimeout;
|
||||
schedule(() => {
|
||||
if (this.status === 'playing') this.speakNext();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private clearWatchdog(): void {
|
||||
if (this.watchdog === null) return;
|
||||
(this.options.clearTimeout ?? clearTimeout)(this.watchdog);
|
||||
this.watchdog = null;
|
||||
}
|
||||
|
||||
private loadVoices(): void {
|
||||
const synthesis = this.synthesis;
|
||||
if (!synthesis) return;
|
||||
let voices: SpeechSynthesisVoice[] = [];
|
||||
try {
|
||||
voices = synthesis.getVoices() ?? [];
|
||||
} catch {
|
||||
voices = [];
|
||||
}
|
||||
this.voices.length = 0;
|
||||
for (const voice of voices) {
|
||||
this.voices.push({
|
||||
id: voice.voiceURI || `${voice.name}|${voice.lang}`,
|
||||
name: voice.name,
|
||||
lang: voice.lang,
|
||||
default: voice.default,
|
||||
});
|
||||
}
|
||||
// A stored voice that this device does not have (settings are per device, the
|
||||
// voice list is per platform) silently falls back to the engine's default
|
||||
// rather than muting the reader.
|
||||
if (this.voiceId && !this.voices.some((voice) => voice.id === this.voiceId)) {
|
||||
this.voiceId = '';
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
(this.options.onState ?? this.noop)(this.snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function findRawVoice(synthesis: SpeechSynthesis, voice: TtsVoice): SpeechSynthesisVoice | null {
|
||||
let voices: SpeechSynthesisVoice[] = [];
|
||||
try {
|
||||
voices = synthesis.getVoices() ?? [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
voices.find((candidate) => (candidate.voiceURI || `${candidate.name}|${candidate.lang}`) === voice.id) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -17,6 +17,13 @@ export interface AppSettings extends ViewSettings {
|
||||
txtEncoding: string;
|
||||
/** Only meaningful for fixed-layout books. */
|
||||
comicDirection: 'ltr' | 'rtl';
|
||||
/** Read-aloud preferences. The voice itself is per device, like the rest. */
|
||||
ttsRate: number;
|
||||
ttsPitch: number;
|
||||
ttsVolume: number;
|
||||
ttsVoice: string;
|
||||
/** Keep reading into the next chapter when the current one runs out. */
|
||||
ttsAutoAdvance: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
@@ -26,8 +33,19 @@ export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
theme: 'light',
|
||||
fit: 'contain',
|
||||
direction: 'ltr',
|
||||
fontFamily: 'inherit',
|
||||
pageMargin: 1.5,
|
||||
textAlign: 'inherit',
|
||||
brightness: 1,
|
||||
pageAnimation: 'slide',
|
||||
tapZone: 'standard',
|
||||
txtEncoding: '',
|
||||
comicDirection: 'ltr',
|
||||
ttsRate: 1,
|
||||
ttsPitch: 1,
|
||||
ttsVolume: 1,
|
||||
ttsVoice: '',
|
||||
ttsAutoAdvance: true,
|
||||
};
|
||||
|
||||
export class SettingsStore {
|
||||
|
||||
+176
-4
@@ -47,6 +47,10 @@
|
||||
*/
|
||||
--reader-font-scale: 1;
|
||||
--reader-line-height: inherit;
|
||||
--reader-font-family: inherit;
|
||||
--reader-text-align: inherit;
|
||||
--reader-page-margin: 1.5rem;
|
||||
--reader-brightness: 1;
|
||||
--reader-measure: 42rem;
|
||||
|
||||
--reader-page-padding-inline: 1.5rem;
|
||||
@@ -151,13 +155,50 @@ body {
|
||||
* `p` and `div`.
|
||||
*/
|
||||
.book-flow {
|
||||
position: relative;
|
||||
max-inline-size: var(--reader-measure);
|
||||
margin-inline: auto;
|
||||
padding-block: var(--reader-page-padding-block);
|
||||
padding-inline: var(--reader-page-padding-inline);
|
||||
/*
|
||||
* The margin control is a *variable* rather than a fixed padding, and the
|
||||
* reader's value rides on the left/right pair only: the block padding is set by
|
||||
* the shell because vertical whitespace on a phone is a different decision
|
||||
* (safe areas) from the measure.
|
||||
*/
|
||||
padding-inline: var(--reader-page-margin);
|
||||
/* The size control scales the root of the injected document only. */
|
||||
font-size: calc(1em * var(--reader-font-scale));
|
||||
line-height: var(--reader-line-height);
|
||||
/*
|
||||
* Both of these are set as inheriting values on the container, never as rules
|
||||
* on the book's own elements. `inherit` is the default in both cases, so "off"
|
||||
* means the author's typography is untouched — which is the whole contract of
|
||||
* this stylesheet. Where the book ships an absolute font stack, this is prefixed
|
||||
* rather than replaced: the book's stack stays first.
|
||||
*/
|
||||
font-family: var(--reader-font-family);
|
||||
text-align: var(--reader-text-align);
|
||||
}
|
||||
|
||||
/*
|
||||
* Brightness.
|
||||
*
|
||||
* A dimming bed-time overlay rather than a change to the palette: the reader's
|
||||
* theme is the author's colours, and darkening them keeps the relationship
|
||||
* between paper and ink. An overlay *on top* would darken the text as much as the
|
||||
* paper, which is what makes a "brightness" slider feel broken. Applied with
|
||||
* `mix-blend-mode: multiply` on a pseudo-element so it costs no extra DOM and
|
||||
* cannot be reached by the book.
|
||||
*/
|
||||
.book-host[data-layout='reflowable']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
background: #000;
|
||||
opacity: calc(1 - var(--reader-brightness));
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.book-host[data-paginated='true'] .book-flow {
|
||||
@@ -374,8 +415,16 @@ book-content ::selection {
|
||||
animation: slide-next 180ms ease-out;
|
||||
}
|
||||
|
||||
.book-host[data-animating='slide-prev'] .book-flow {
|
||||
animation: slide-prev 180ms ease-out;
|
||||
.book-host[data-animating='slide-previous'] .book-flow {
|
||||
animation: slide-previous 180ms ease-out;
|
||||
}
|
||||
|
||||
.book-host[data-animating='fade-next'] .book-flow {
|
||||
animation: fade-next 160ms ease-out;
|
||||
}
|
||||
|
||||
.book-host[data-animating='fade-previous'] .book-flow {
|
||||
animation: fade-previous 160ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-next {
|
||||
@@ -389,7 +438,7 @@ book-content ::selection {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-prev {
|
||||
@keyframes slide-previous {
|
||||
from {
|
||||
transform: translateX(-6%);
|
||||
opacity: 0.6;
|
||||
@@ -400,12 +449,63 @@ book-content ::selection {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-next {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-previous {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.book-host[data-animating] .book-flow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Read-aloud helpers
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* The sentence being spoken.
|
||||
*
|
||||
* Drawn as a translucent block *behind* the text, positioned from a `Range`'s
|
||||
* client rect. The alternative — wrapping the sentence in a `<span>` — would edit
|
||||
* the publisher's markup and, worse, change the line box metrics for that phrase,
|
||||
* which repaginates the chapter the moment playback starts.
|
||||
*/
|
||||
.reader-speech-highlight {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--reader-accent) 34%, transparent);
|
||||
transition: top 120ms linear, left 120ms linear, width 120ms linear, height 120ms linear;
|
||||
}
|
||||
|
||||
/* The overlay needs a stacking context that puts it under the text but above the
|
||||
page background; `z-index: -1` alone would fall behind an opaque body. */
|
||||
.book-flow {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reader-speech-highlight {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Chrome: header, shelf, panels
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -750,6 +850,78 @@ book-content ::selection {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- read-aloud bar ---- */
|
||||
|
||||
/*
|
||||
* The bar sits above the footer and, unlike the footer, is *not* hidden with the
|
||||
* chrome: a reader listening while walking needs the play/pause without the
|
||||
* header covering the sentence being read.
|
||||
*/
|
||||
.tts-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
padding-bottom: calc(0.35rem + env(safe-area-inset-bottom));
|
||||
background: var(--reader-surface);
|
||||
border-top: 1px solid var(--reader-line);
|
||||
}
|
||||
|
||||
.tts-bar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tts-bar .tts-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.tts-bar .tts-text {
|
||||
font-size: 0.78rem;
|
||||
color: var(--reader-ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tts-range {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
accent-color: var(--reader-accent);
|
||||
}
|
||||
|
||||
.chip {
|
||||
appearance: none;
|
||||
border: 1px solid var(--reader-line);
|
||||
background: var(--reader-surface);
|
||||
color: var(--reader-ink);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---- settings panel structure ---- */
|
||||
|
||||
.section-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--reader-muted);
|
||||
margin: 1.1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.field[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- status ---- */
|
||||
|
||||
.status-bar {
|
||||
|
||||
+555
-25
@@ -9,6 +9,8 @@ import { ReaderView, type Position, type ViewSettings } from './reader-view.ts';
|
||||
import { attachGestures } from './gestures.ts';
|
||||
import { clear, el, percent } from './dom.ts';
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
import { TtsEngine, type TtsSnapshot } from '../render/tts.ts';
|
||||
import type { SpokenChunk } from '../render/tts-text.ts';
|
||||
|
||||
export interface ReaderScreenOptions {
|
||||
api: ReaderApi;
|
||||
@@ -49,6 +51,16 @@ export class ReaderScreen {
|
||||
private readonly tocPanel: HTMLDivElement;
|
||||
private readonly tocList: HTMLUListElement;
|
||||
private readonly settingsPanel: HTMLDivElement;
|
||||
private readonly ttsBar: HTMLDivElement;
|
||||
private readonly ttsChip: HTMLElement;
|
||||
private readonly ttsRangeInput: HTMLInputElement;
|
||||
private readonly ttsLabel: HTMLSpanElement;
|
||||
private readonly ttsPlayButton: HTMLButtonElement;
|
||||
private tts: TtsEngine | 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;
|
||||
|
||||
private view: ReaderView | null = null;
|
||||
private doc: BookDoc | null = null;
|
||||
@@ -113,9 +125,29 @@ export class ReaderScreen {
|
||||
|
||||
this.settingsPanel = this.buildSettingsPanel();
|
||||
|
||||
this.ttsLabel = el('span', { className: 'tts-text', text: '' });
|
||||
this.ttsChip = el('button', {
|
||||
className: 'chip',
|
||||
text: '从头朗读',
|
||||
attrs: { type: 'button', 'aria-label': '朗读' },
|
||||
on: { click: () => void this.speakFromReaderPosition() },
|
||||
});
|
||||
this.ttsPlayButton = el('button', {
|
||||
className: 'icon-button',
|
||||
text: '▶',
|
||||
attrs: { type: 'button', 'aria-label': '播放/暂停朗读' },
|
||||
on: { click: () => this.toggleSpeech() },
|
||||
}) as HTMLButtonElement;
|
||||
this.ttsRangeInput = el('input', {
|
||||
className: 'tts-range',
|
||||
attrs: { type: 'range', min: '0', max: '0', step: '1', value: '0', 'aria-label': '朗读进度' },
|
||||
on: { input: (event) => this.onSpeechScrub(Number((event.target as HTMLInputElement).value)) },
|
||||
}) as HTMLInputElement;
|
||||
this.ttsBar = this.buildTtsBar();
|
||||
|
||||
this.element = el('div', {
|
||||
className: 'reader-screen',
|
||||
children: [this.topbar, this.statusBar, this.stage, this.footer, this.tocPanel, this.settingsPanel],
|
||||
children: [this.topbar, this.statusBar, this.stage, this.footer, this.ttsBar, this.tocPanel, this.settingsPanel],
|
||||
}) as HTMLDivElement;
|
||||
this.element.style.cssText = 'flex:1 1 auto;min-height:0;display:flex;flex-direction:column;position:relative;';
|
||||
|
||||
@@ -152,6 +184,9 @@ export class ReaderScreen {
|
||||
|
||||
dispose(): void {
|
||||
this.loadingToken += 1;
|
||||
this.tts?.dispose();
|
||||
this.tts = null;
|
||||
this.ttsBar.hidden = true;
|
||||
this.flushProgress();
|
||||
if (this.progressTimer) clearTimeout(this.progressTimer);
|
||||
this.detachGestures?.();
|
||||
@@ -247,9 +282,18 @@ export class ReaderScreen {
|
||||
container: this.stage,
|
||||
doc,
|
||||
onPositionChange: (position) => this.onPosition(position),
|
||||
onChapterChange: (_index, section) => {
|
||||
onChapterChange: (index, section) => {
|
||||
this.chapterLabel.textContent = section.label;
|
||||
this.highlightToc(section.id);
|
||||
// The previous chapter's sentence nodes are gone the moment this fires,
|
||||
// so the engine is holding a queue that can never be spoken. It is told
|
||||
// to refill from the new chapter rather than left to speak into the void:
|
||||
// that is what makes the highlight follow the voice across a boundary
|
||||
// instead of stopping dead while the toolbar still says "playing".
|
||||
if (this.tts?.active && index !== this.spokenSectionIndex) {
|
||||
this.spokenSectionIndex = index;
|
||||
void this.tts.jump(0);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.view.applySettings(this.viewSettings());
|
||||
@@ -262,24 +306,32 @@ export class ReaderScreen {
|
||||
}
|
||||
|
||||
this.detachGestures = attachGestures(this.stage, {
|
||||
onTapZone: (zone) => {
|
||||
if (zone === 'toggle-chrome') this.setChromeVisible(!this.chromeVisible);
|
||||
else if (zone === 'next') void this.view?.next();
|
||||
else void this.view?.previous();
|
||||
},
|
||||
onTapZone: (zone) => this.onTapZone(zone),
|
||||
onSwipe: (direction) => {
|
||||
// Swiping is only a page turn in fixed layout or paged mode. In scroll
|
||||
// mode a horizontal swipe has no meaning and hijacking it would fight
|
||||
// the reader's own scrolling.
|
||||
if (doc.layout !== 'fixed' && this.settings.mode !== 'paged') return;
|
||||
if (direction === 'left') void this.view?.next();
|
||||
else if (direction === 'right') void this.view?.previous();
|
||||
if (direction === 'left') void this.turnPage('next');
|
||||
else if (direction === 'right') void this.turnPage('previous');
|
||||
},
|
||||
onDragDelta: () => undefined,
|
||||
onLongPress: () => this.setChromeVisible(true),
|
||||
hasSelection: () => hasTextSelection(this.stage),
|
||||
});
|
||||
|
||||
// Hardware keys, which is how a Bluetooth page-turner and an Android volume
|
||||
// rocker reach the reader (the shell maps them to ArrowLeft/Right). Space is
|
||||
// the desktop convention and costs nothing.
|
||||
this.listeners.push(
|
||||
bindKeys(this.element, (key) => {
|
||||
if (key === 'ArrowRight' || key === 'PageDown') void this.turnPage('next');
|
||||
else if (key === 'ArrowLeft' || key === 'PageUp') void this.turnPage('previous');
|
||||
else if (key === ' ') this.toggleSpeech();
|
||||
else if (key === 'Escape') this.setChromeVisible(false);
|
||||
}),
|
||||
);
|
||||
|
||||
void this.renderToc();
|
||||
}
|
||||
|
||||
@@ -291,9 +343,47 @@ export class ReaderScreen {
|
||||
theme: this.settings.theme,
|
||||
fit: this.settings.fit,
|
||||
direction: this.doc?.layout === 'fixed' ? this.settings.comicDirection : (this.doc?.direction ?? 'ltr'),
|
||||
fontFamily: this.settings.fontFamily,
|
||||
pageMargin: this.settings.pageMargin,
|
||||
textAlign: this.settings.textAlign,
|
||||
brightness: this.settings.brightness,
|
||||
pageAnimation: this.settings.pageAnimation,
|
||||
tapZone: this.settings.tapZone,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A page turn with the animation and the spoken-position bookkeeping.
|
||||
*
|
||||
* Every page turn in this screen goes through here. That is not tidiness: the
|
||||
* TTS engine anchors itself to a DOM node in the chapter that is on screen, so
|
||||
* a turn that bypassed this method would leave the engine speaking from a
|
||||
* detached node, which sounds exactly like the reader being ignored.
|
||||
*/
|
||||
private async turnPage(direction: 'next' | 'previous'): Promise<void> {
|
||||
const view = this.view;
|
||||
if (!view) return;
|
||||
const moved = direction === 'next' ? await view.next() : await view.previous();
|
||||
if (!moved) {
|
||||
this.setStatus('idle', direction === 'next' ? '已经是最后一页' : '已经是第一页');
|
||||
window.setTimeout(() => this.hideStatus(), 1600);
|
||||
return;
|
||||
}
|
||||
view.animatePage(direction);
|
||||
this.pendingSpeechAnchor = null;
|
||||
}
|
||||
|
||||
/** Tap zones, honouring the reader's handedness. */
|
||||
private onTapZone(zone: 'previous' | 'toggle-chrome' | 'next'): void {
|
||||
if (zone === 'toggle-chrome') {
|
||||
this.setChromeVisible(!this.chromeVisible);
|
||||
return;
|
||||
}
|
||||
const reversed = this.settings.tapZone === 'reversed';
|
||||
const forward = reversed ? zone === 'previous' : zone === 'next';
|
||||
void this.turnPage(forward ? 'next' : 'previous');
|
||||
}
|
||||
|
||||
private async restorePosition(book: Book, token: number): Promise<void> {
|
||||
const local = await this.options.offline.getProgress(book.id);
|
||||
let locator = local?.locator ?? '';
|
||||
@@ -335,6 +425,7 @@ export class ReaderScreen {
|
||||
this.pageLabel.textContent = percent(position.percentage);
|
||||
if (position.chapterTitle) this.chapterLabel.textContent = position.chapterTitle;
|
||||
this.pendingPosition = position;
|
||||
this.recordSpeechAnchor();
|
||||
|
||||
// Debounced: a scroll produces a position per frame, and each one would
|
||||
// otherwise become a write to IndexedDB and a network sync.
|
||||
@@ -426,7 +517,38 @@ export class ReaderScreen {
|
||||
|
||||
private toggleSettings(): void {
|
||||
this.settingsPanel.hidden = !this.settingsPanel.hidden;
|
||||
if (!this.settingsPanel.hidden) this.tocPanel.hidden = true;
|
||||
if (!this.settingsPanel.hidden) {
|
||||
this.tocPanel.hidden = true;
|
||||
this.filterSettingsRows();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-reads the engine's voice list into the settings panel's select. */
|
||||
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 signature = voices.map((voice) => voice.id).join('|');
|
||||
if (select.dataset['signature'] === signature) return;
|
||||
select.dataset['signature'] = signature;
|
||||
const current = this.settings.ttsVoice;
|
||||
select.replaceChildren();
|
||||
const follow = document.createElement('option');
|
||||
follow.value = '';
|
||||
follow.textContent = '跟随系统';
|
||||
select.append(follow);
|
||||
for (const voice of voices) {
|
||||
const option = document.createElement('option');
|
||||
option.value = voice.id;
|
||||
option.textContent = `${voice.name} · ${voice.lang}${voice.default ? ' · 默认' : ''}`;
|
||||
option.selected = voice.id === current;
|
||||
select.append(option);
|
||||
}
|
||||
}
|
||||
|
||||
private async renderToc(): Promise<void> {
|
||||
@@ -465,37 +587,79 @@ export class ReaderScreen {
|
||||
private buildSettingsPanel(): HTMLDivElement {
|
||||
const body = el('div', { className: 'panel-body' });
|
||||
|
||||
// -- 排版 --
|
||||
body.append(this.sectionTitle('排版'));
|
||||
|
||||
body.append(
|
||||
this.settingsRow('翻页方式', ['scroll', 'paged'], this.settings.mode, (value) =>
|
||||
this.updateSetting({ mode: value as 'scroll' | 'paged' }), (value) => (value === 'scroll' ? '滚动' : '翻页')),
|
||||
);
|
||||
|
||||
const scaleRow = el('div', { className: 'field' });
|
||||
const scaleValue = el('label', { text: `字号 ${Math.round(this.settings.fontScale * 100)}%` });
|
||||
const scaleInput = el('input', {
|
||||
attrs: { type: 'range', min: '0.8', max: '2.2', step: '0.05', value: String(this.settings.fontScale) },
|
||||
on: {
|
||||
input: (event) => {
|
||||
const value = Number((event.target as HTMLInputElement).value);
|
||||
scaleValue.textContent = `字号 ${Math.round(value * 100)}%`;
|
||||
void this.updateSetting({ fontScale: value });
|
||||
},
|
||||
},
|
||||
});
|
||||
scaleRow.append(scaleValue, scaleInput);
|
||||
body.append(scaleRow);
|
||||
body.append(
|
||||
this.slider('字号', this.settings.fontScale, { min: 0.8, max: 2.2, step: 0.05 }, (value) =>
|
||||
`${Math.round(value * 100)}%`, (value) => this.updateSetting({ fontScale: value })),
|
||||
);
|
||||
|
||||
body.append(
|
||||
this.settingsRow('行距', ['inherit', '1.5', '1.8', '2.1'], this.settings.lineHeight, (value) =>
|
||||
this.settingsRow('行距', ['inherit', '1.4', '1.6', '1.8', '2.1'], this.settings.lineHeight, (value) =>
|
||||
this.updateSetting({ lineHeight: value }), (value) => (value === 'inherit' ? '原书' : value)),
|
||||
);
|
||||
|
||||
body.append(
|
||||
this.slider('页边距', this.settings.pageMargin, { min: 0, max: 4, step: 0.25 }, (value) =>
|
||||
`${value.toFixed(2)}rem`, (value) => this.updateSetting({ pageMargin: value })),
|
||||
);
|
||||
|
||||
body.append(
|
||||
this.settingsRow('对齐', ['inherit', 'start', 'justify'], this.settings.textAlign, (value) =>
|
||||
this.updateSetting({ textAlign: value as AppSettings['textAlign'] }), (value) =>
|
||||
value === 'inherit' ? '原书' : value === 'start' ? '左对齐' : '两端对齐'),
|
||||
);
|
||||
|
||||
// The stacks are the ones a Chinese reading app is expected to offer: a
|
||||
// system stack, two serif faces that are actually present on phones, and
|
||||
// "原书" which is the default and means "do not touch the book's stack".
|
||||
body.append(
|
||||
this.selectRow(
|
||||
'字体',
|
||||
[
|
||||
{ value: 'inherit', label: '原书' },
|
||||
{ value: 'system-ui, -apple-system, "Noto Sans SC", sans-serif', label: '系统黑体' },
|
||||
{ value: '"Songti SC", "Noto Serif SC", "Source Han Serif SC", SimSun, serif', label: '宋体' },
|
||||
{ value: '"Kaiti SC", KaiTi, "Noto Serif SC", serif', label: '楷体' },
|
||||
{ value: '"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif', label: '苹方/雅黑' },
|
||||
],
|
||||
this.settings.fontFamily,
|
||||
(value) => void this.updateSetting({ fontFamily: value }),
|
||||
),
|
||||
);
|
||||
|
||||
body.append(
|
||||
this.settingsRow('主题', ['light', 'sepia', 'dark'], this.settings.theme, (value) =>
|
||||
this.updateSetting({ theme: value as 'light' | 'sepia' | 'dark' }), (value) =>
|
||||
value === 'light' ? '白' : value === 'sepia' ? '米黄' : '夜间'),
|
||||
);
|
||||
|
||||
body.append(
|
||||
this.slider('亮度', this.settings.brightness, { min: 0.35, max: 1, step: 0.05 }, (value) =>
|
||||
`${Math.round(value * 100)}%`, (value) => this.updateSetting({ brightness: value })),
|
||||
);
|
||||
|
||||
// -- 翻页 --
|
||||
body.append(this.sectionTitle('翻页'));
|
||||
|
||||
body.append(
|
||||
this.settingsRow('点击区域', ['standard', 'reversed'], this.settings.tapZone, (value) =>
|
||||
this.updateSetting({ tapZone: value as AppSettings['tapZone'] }), (value) =>
|
||||
value === 'standard' ? '左退右进' : '左进右退'),
|
||||
);
|
||||
|
||||
body.append(
|
||||
this.settingsRow('翻页动画', ['slide', 'fade', 'none'], this.settings.pageAnimation, (value) =>
|
||||
this.updateSetting({ pageAnimation: value as AppSettings['pageAnimation'] }), (value) =>
|
||||
value === 'slide' ? '滑动' : value === 'fade' ? '淡入' : '无'),
|
||||
);
|
||||
|
||||
const fitRow = this.settingsRow('图片适配', ['contain', 'width'], this.settings.fit, (value) =>
|
||||
this.updateSetting({ fit: value as 'contain' | 'width' }), (value) => (value === 'contain' ? '完整' : '适宽'));
|
||||
fitRow.dataset['only'] = 'fixed';
|
||||
@@ -506,6 +670,11 @@ export class ReaderScreen {
|
||||
directionRow.dataset['only'] = 'fixed';
|
||||
body.append(directionRow);
|
||||
|
||||
// -- 朗读 --
|
||||
body.append(this.sectionTitle('朗读'));
|
||||
body.append(this.buildSpeechSettings());
|
||||
|
||||
// -- 文本 --
|
||||
const encodingRow = this.settingsRow('TXT 编码', ['', 'utf-8', 'gb18030', 'big5', 'utf-16le'], this.settings.txtEncoding, (value) =>
|
||||
this.updateSetting({ txtEncoding: value }), (value) => (value === '' ? '自动' : value));
|
||||
encodingRow.dataset['only'] = 'txt';
|
||||
@@ -518,6 +687,162 @@ export class ReaderScreen {
|
||||
}) as HTMLDivElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows only the rows that apply to the open book.
|
||||
*
|
||||
* A settings panel that offers "翻页方向" for a novel is a panel the reader
|
||||
* stops reading, so the rows carry a `data-only` marker and this hides the rest.
|
||||
* `txt` is the format of the file, not the layout: a TXT book is reflowable but
|
||||
* is the only thing an encoding override means anything for.
|
||||
*/
|
||||
private filterSettingsRows(): void {
|
||||
const layout = this.doc?.layout ?? 'reflowable';
|
||||
const isTxt = this.doc?.format === 'txt';
|
||||
for (const row of this.settingsPanel.querySelectorAll<HTMLElement>('[data-only]')) {
|
||||
const only = row.dataset['only'];
|
||||
const relevant = only === 'fixed' ? layout === 'fixed' : only === 'txt' ? isTxt : true;
|
||||
row.hidden = !relevant;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers which sentence the reader was looking at.
|
||||
*
|
||||
* Used by "read from here". It is recorded on every position change rather than
|
||||
* computed when playback starts, because at that moment the reader has already
|
||||
* dismissed the chrome and a measurement taken then would have to guess.
|
||||
*/
|
||||
private recordSpeechAnchor(): void {
|
||||
const view = this.view;
|
||||
if (!view || !TtsEngine.isSupported()) 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;
|
||||
this.pendingSpeechAnchor = view.speechAnchor();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private buildSpeechSettings(): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
if (!TtsEngine.isSupported()) {
|
||||
fragment.append(
|
||||
el('div', {
|
||||
className: 'notice',
|
||||
text: '当前环境不支持系统朗读。Android 客户端会使用系统 TTS 引擎,浏览器中请使用 Chrome / Edge。',
|
||||
}),
|
||||
);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
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 })),
|
||||
);
|
||||
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 })),
|
||||
);
|
||||
|
||||
fragment.append(
|
||||
this.settingsRow('章节播完', ['auto', 'stop'], this.settings.ttsAutoAdvance ? 'auto' : 'stop', (value) =>
|
||||
this.updateSpeechSetting({ ttsAutoAdvance: value === 'auto' }), (value) =>
|
||||
value === 'auto' ? '继续下一章' : '停止'),
|
||||
);
|
||||
|
||||
fragment.append(
|
||||
el('button', {
|
||||
className: 'button',
|
||||
text: '从头朗读这一章',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => void this.speakFromReaderPosition() },
|
||||
}),
|
||||
);
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
private sectionTitle(text: string): HTMLDivElement {
|
||||
return el('div', { className: 'section-title', text }) as HTMLDivElement;
|
||||
}
|
||||
|
||||
/** A labelled range input whose value label updates as it is dragged. */
|
||||
private slider(
|
||||
label: string,
|
||||
value: number,
|
||||
range: { min: number; max: number; step: number },
|
||||
format: (value: number) => string,
|
||||
onChange: (value: number) => void,
|
||||
): HTMLDivElement {
|
||||
const field = el('div', { className: 'field' });
|
||||
const valueLabel = el('label', { text: `${label} ${format(value)}` });
|
||||
const input = el('input', {
|
||||
attrs: {
|
||||
type: 'range',
|
||||
min: String(range.min),
|
||||
max: String(range.max),
|
||||
step: String(range.step),
|
||||
value: String(value),
|
||||
},
|
||||
on: {
|
||||
input: (event) => {
|
||||
const next = Number((event.target as HTMLInputElement).value);
|
||||
valueLabel.textContent = `${label} ${format(next)}`;
|
||||
onChange(next);
|
||||
},
|
||||
},
|
||||
});
|
||||
field.append(valueLabel, input);
|
||||
return field;
|
||||
}
|
||||
|
||||
/** A labelled native select, for the lists that outgrow a segmented control. */
|
||||
private selectRow(
|
||||
label: string,
|
||||
options: Array<{ value: string; label: string }>,
|
||||
current: string,
|
||||
onChange: (value: string) => void,
|
||||
): HTMLDivElement {
|
||||
const select = el('select', {
|
||||
attrs: label === '语音' ? { 'data-voice': 'true' } : {},
|
||||
on: { change: (event) => onChange((event.target as HTMLSelectElement).value) },
|
||||
}) as HTMLSelectElement;
|
||||
for (const option of options) {
|
||||
const node = document.createElement('option');
|
||||
node.value = option.value;
|
||||
node.textContent = option.label;
|
||||
if (option.value === current) node.selected = true;
|
||||
select.append(node);
|
||||
}
|
||||
return el('div', {
|
||||
className: 'field',
|
||||
children: [el('label', { text: label }), select],
|
||||
}) as HTMLDivElement;
|
||||
}
|
||||
|
||||
private settingsRow(
|
||||
label: string,
|
||||
values: string[],
|
||||
@@ -563,6 +888,186 @@ export class ReaderScreen {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- read aloud ----
|
||||
|
||||
/**
|
||||
* The read-aloud bar.
|
||||
*
|
||||
* A separate strip rather than a row inside the footer, for a reason that only
|
||||
* shows up in use: the footer is toggled off with the chrome, and a reader
|
||||
* listening to a book while walking wants to keep the controls reachable
|
||||
* without the header covering the text. The bar is therefore tied to the
|
||||
* *engine's* state, not to the chrome's.
|
||||
*/
|
||||
private buildTtsBar(): HTMLDivElement {
|
||||
return el('div', {
|
||||
className: 'tts-bar',
|
||||
attrs: { hidden: true },
|
||||
children: [
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '⏮',
|
||||
attrs: { type: 'button', 'aria-label': '上一句' },
|
||||
on: { click: () => void this.tts?.previous() },
|
||||
}),
|
||||
this.ttsPlayButton,
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '⏭',
|
||||
attrs: { type: 'button', 'aria-label': '下一句' },
|
||||
on: { click: () => void this.tts?.next() },
|
||||
}),
|
||||
el('div', { className: 'tts-main', children: [this.ttsLabel, this.ttsRangeInput] }),
|
||||
this.ttsChip,
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '✕',
|
||||
attrs: { type: 'button', 'aria-label': '停止朗读' },
|
||||
on: { click: () => this.stopSpeech() },
|
||||
}),
|
||||
],
|
||||
}) as HTMLDivElement;
|
||||
}
|
||||
|
||||
private ensureTts(): TtsEngine {
|
||||
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),
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the engine with the next batch of sentences.
|
||||
*
|
||||
* When the queue runs out mid-chapter the engine keeps calling until it gets an
|
||||
* empty list, which is what makes "continue into the next chapter" work without
|
||||
* the engine knowing anything about chapters.
|
||||
*/
|
||||
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[];
|
||||
if (from < chunks.length && view.currentSectionIndex() === this.spokenSectionIndex) {
|
||||
return { chunks, startIndex: from };
|
||||
}
|
||||
// The queue is exhausted: look for the next chapter, but only when the reader
|
||||
// asked for it. Stopping at a chapter end is a legitimate preference — it is
|
||||
// how an audiobook's "one chapter per session" works.
|
||||
if (!this.settings.ttsAutoAdvance) return { chunks, startIndex: from };
|
||||
const next = view.currentSectionIndex() + 1;
|
||||
if (next >= view.sectionCount) return { chunks: [], startIndex: 0 };
|
||||
await view.open(next, 0);
|
||||
this.spokenSectionIndex = next;
|
||||
chunks = view.speechChunks as SpokenChunk[];
|
||||
return { chunks, startIndex: 0 };
|
||||
}
|
||||
|
||||
private toggleSpeech(): void {
|
||||
if (!TtsEngine.isSupported()) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
void this.speakFromReaderPosition();
|
||||
}
|
||||
|
||||
private stopSpeech(): void {
|
||||
this.tts?.stop();
|
||||
this.ttsBar.hidden = true;
|
||||
this.view?.clearSpeechHighlight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reading from the sentence the reader is looking at.
|
||||
*
|
||||
* The view answers that question by measuring: a selected sentence wins, then
|
||||
* the first sentence at the leading edge of the reading area. Measuring rather
|
||||
* than scaling the position fraction is what makes this work in paged mode,
|
||||
* where the leading edge is a column boundary and a fraction of the chapter
|
||||
* would land a paragraph away from what the reader is looking at.
|
||||
*/
|
||||
private async speakFromReaderPosition(): Promise<void> {
|
||||
const view = this.view;
|
||||
if (!view) return;
|
||||
const engine = this.ensureTts();
|
||||
this.ttsBar.hidden = false;
|
||||
this.spokenSectionIndex = view.currentSectionIndex();
|
||||
const chunks = view.speechChunks as SpokenChunk[];
|
||||
if (chunks.length === 0) {
|
||||
// A fixed-layout page has no text; say so instead of showing an empty bar.
|
||||
this.setStatus('idle', '这一页没有可朗读的文字');
|
||||
window.setTimeout(() => this.hideStatus(), 2400);
|
||||
this.ttsBar.hidden = true;
|
||||
return;
|
||||
}
|
||||
// A live selection wins: the reader who selected a paragraph and pressed play
|
||||
// has said exactly which paragraph they mean. Otherwise the sentence at the
|
||||
// leading edge is re-measured, because the reader may have scrolled or turned
|
||||
// a page since the anchor was recorded.
|
||||
const anchor = view.speechAnchorFromSelection() ?? view.speechAnchor() ?? this.pendingSpeechAnchor;
|
||||
const start = anchor ? chunks.indexOf(anchor) : -1;
|
||||
await engine.play(start >= 0 ? start : 0);
|
||||
}
|
||||
|
||||
private onSpeechScrub(index: number): void {
|
||||
const engine = this.tts;
|
||||
if (!engine) return;
|
||||
const total = engine.snapshot.total;
|
||||
// Scrubbing to the very end means "stop", not "play the last sentence".
|
||||
if (total === 0 || index >= total) {
|
||||
this.stopSpeech();
|
||||
return;
|
||||
}
|
||||
void engine.jump(index);
|
||||
}
|
||||
|
||||
private renderSpeechState(snapshot: TtsSnapshot): void {
|
||||
if (snapshot.state === 'unsupported') {
|
||||
this.setStatus('error', snapshot.error || '当前浏览器不支持朗读');
|
||||
this.ttsBar.hidden = true;
|
||||
return;
|
||||
}
|
||||
const active = snapshot.state === 'playing' || snapshot.state === 'paused';
|
||||
if (active) this.ttsBar.hidden = false;
|
||||
this.ttsBar.dataset['state'] = snapshot.state;
|
||||
this.ttsPlayButton.textContent = snapshot.state === 'playing' ? '⏸' : '▶';
|
||||
this.ttsPlayButton.setAttribute('aria-label', snapshot.state === 'playing' ? '暂停朗读' : '开始朗读');
|
||||
this.ttsLabel.textContent = snapshot.chunk || (snapshot.error ? snapshot.error : '准备朗读…');
|
||||
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));
|
||||
if (snapshot.error) {
|
||||
this.setStatus('error', snapshot.error);
|
||||
window.setTimeout(() => this.hideStatus(), 3200);
|
||||
}
|
||||
}
|
||||
|
||||
private updateSpeechSetting(patch: Partial<AppSettings>): void {
|
||||
Object.assign(this.settings, patch);
|
||||
this.options.onSettingsChange(patch);
|
||||
const engine = this.tts;
|
||||
if (!engine) return;
|
||||
if (patch.ttsRate !== undefined) engine.setRate(patch.ttsRate);
|
||||
if (patch.ttsPitch !== undefined) engine.setPitch(patch.ttsPitch);
|
||||
if (patch.ttsVolume !== undefined) engine.setVolume(patch.ttsVolume);
|
||||
if (patch.ttsVoice !== undefined) engine.setVoice(patch.ttsVoice);
|
||||
}
|
||||
|
||||
// ---- status ----
|
||||
|
||||
private bindSyncStatus(): void {
|
||||
@@ -630,6 +1135,31 @@ function addDebouncedListener(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardware page-turn keys.
|
||||
*
|
||||
* The Android shell synthesises `ArrowRight`/`ArrowLeft` from the volume rocker
|
||||
* (see `android/contract.md` §4), which is how a phone in a pocket becomes a
|
||||
* page-turner. The same handler therefore serves a Bluetooth remote and a
|
||||
* desktop keyboard, with no per-host branching.
|
||||
*/
|
||||
function bindKeys(target: HTMLElement, handler: (key: string) => void): () => void {
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
const element = event.target as HTMLElement | null;
|
||||
const tag = element?.tagName ?? '';
|
||||
// A typing surface owns the keys: the settings panel has range inputs, and
|
||||
// space on a focused control must not also turn the page.
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || element?.isContentEditable) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
// A panel that is open owns the keyboard: the arrow keys are how a range
|
||||
// input is adjusted, and the settings panel is a column of range inputs.
|
||||
if (target.querySelector('.panel:not([hidden])')) return;
|
||||
handler(event.key);
|
||||
};
|
||||
target.addEventListener('keydown', onKeyDown);
|
||||
return () => target.removeEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
function hasTextSelection(root: HTMLElement): boolean {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) return false;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BookDoc, Section } from '../formats/types.ts';
|
||||
import { ResourceResolver, hydrateResources } from './resources.ts';
|
||||
import { createBookHost, extractBody, extractInlineStyles, sanitiseInjectedContent, type BookShadowHost } from './shadow.ts';
|
||||
import { bookPercentage, formatLocator, parseLocator } from './locator.ts';
|
||||
import { collectSpokenChunks, type SpokenChunk } from '../render/tts-text.ts';
|
||||
|
||||
export interface ReaderViewOptions {
|
||||
container: HTMLElement;
|
||||
@@ -20,6 +21,10 @@ export interface Position {
|
||||
chapterTitle: string;
|
||||
}
|
||||
|
||||
export type TextAlign = 'inherit' | 'start' | 'justify';
|
||||
export type PageAnimation = 'none' | 'slide' | 'fade';
|
||||
export type TapZone = 'standard' | 'reversed';
|
||||
|
||||
export interface ViewSettings {
|
||||
/** 'scroll' keeps the author's continuous flow; 'paged' paginates by column. */
|
||||
mode: 'scroll' | 'paged';
|
||||
@@ -29,6 +34,23 @@ export interface ViewSettings {
|
||||
/** Fixed-layout only. */
|
||||
fit: 'contain' | 'width';
|
||||
direction: 'ltr' | 'rtl';
|
||||
/** A font stack offered before the book's own, for books that ship no stack. */
|
||||
fontFamily: string;
|
||||
/**
|
||||
* Horizontal padding inside the reading column, in rem.
|
||||
*
|
||||
* A number rather than a keyword because the useful range differs per device:
|
||||
* a phone wants 0.75rem to fit a sentence per line, a tablet wants 3rem to stop
|
||||
* the line becoming unreadably long.
|
||||
*/
|
||||
pageMargin: number;
|
||||
/** `inherit` keeps whatever the book asked for. */
|
||||
textAlign: TextAlign;
|
||||
/** Darkens the page without changing the theme; for reading in bed. */
|
||||
brightness: number;
|
||||
pageAnimation: PageAnimation;
|
||||
/** Which side of the screen turns forward; handedness is a real preference. */
|
||||
tapZone: TapZone;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: ViewSettings = {
|
||||
@@ -38,6 +60,12 @@ const DEFAULT_SETTINGS: ViewSettings = {
|
||||
theme: 'light',
|
||||
fit: 'contain',
|
||||
direction: 'ltr',
|
||||
fontFamily: 'inherit',
|
||||
pageMargin: 1.5,
|
||||
textAlign: 'inherit',
|
||||
brightness: 1,
|
||||
pageAnimation: 'slide',
|
||||
tapZone: 'standard',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,6 +89,9 @@ export class ReaderView {
|
||||
private readonly options: ReaderViewOptions;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private currentObjectUrl: string | null = null;
|
||||
private animationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private spokenChunks: SpokenChunk[] = [];
|
||||
private speechHighlight: HTMLElement | null = null;
|
||||
|
||||
constructor(options: ReaderViewOptions) {
|
||||
this.options = options;
|
||||
@@ -92,6 +123,7 @@ export class ReaderView {
|
||||
}
|
||||
|
||||
applySettings(next?: Partial<ViewSettings>): void {
|
||||
const previous = this.settings;
|
||||
if (next) this.settings = { ...this.settings, ...next };
|
||||
document.documentElement.dataset['theme'] = this.settings.theme;
|
||||
this.host.setAttribute('data-paginated', String(this.settings.mode === 'paged'));
|
||||
@@ -99,10 +131,42 @@ export class ReaderView {
|
||||
// `inherit` is what keeps the author's line-height; only an explicit user
|
||||
// choice replaces it.
|
||||
this.container.style.setProperty('--reader-line-height', this.settings.lineHeight);
|
||||
// Same reasoning for the font stack: the default is `inherit`, so a book
|
||||
// that ships its own stack keeps it. The reader's choice is offered first so
|
||||
// a book that ships *nothing* looks like the rest of their library.
|
||||
this.container.style.setProperty('--reader-font-family', this.settings.fontFamily);
|
||||
this.container.style.setProperty('--reader-text-align', this.settings.textAlign);
|
||||
this.container.style.setProperty('--reader-page-margin', `${this.settings.pageMargin}rem`);
|
||||
this.container.style.setProperty('--reader-brightness', String(this.settings.brightness));
|
||||
this.host.style.direction = this.settings.direction;
|
||||
// Toggling pagination changes the column count, so the stored offset has to
|
||||
// be re-applied after the browser has laid out the new column box.
|
||||
requestAnimationFrame(() => this.restoreOffset());
|
||||
|
||||
// A page turn is the only place an animation is wanted, and only the two
|
||||
// settings that change where a page *is* can start one.
|
||||
if (next && previous.mode !== this.settings.mode) this.animatePage('none');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes a page-turn animation.
|
||||
*
|
||||
* On the host rather than on the flow, because the flow's own transform would
|
||||
* fight the column layout in paged mode. `data-animating` is cleared by a
|
||||
* timer rather than by `animationend`: the same animation may be requested
|
||||
* twice in a row (a fast double tap), and `animationend` would then never fire
|
||||
* for the second one, leaving the host stuck in an animating state.
|
||||
*/
|
||||
animatePage(direction: 'next' | 'previous' | 'none'): void {
|
||||
if (this.settings.pageAnimation === 'none' || direction === 'none') return;
|
||||
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
const value = `${this.settings.pageAnimation}-${direction}`;
|
||||
this.host.setAttribute('data-animating', value);
|
||||
if (this.animationTimer !== null) clearTimeout(this.animationTimer);
|
||||
this.animationTimer = setTimeout(() => {
|
||||
this.animationTimer = null;
|
||||
this.host.removeAttribute('data-animating');
|
||||
}, 220);
|
||||
}
|
||||
|
||||
/** Opens a section by index, optionally at a fractional offset within it. */
|
||||
@@ -119,6 +183,7 @@ export class ReaderView {
|
||||
await this.renderReflowable(section);
|
||||
}
|
||||
this.options.onChapterChange?.(index, section);
|
||||
this.refreshSpokenChunks();
|
||||
this.restoreOffset();
|
||||
this.emitPosition();
|
||||
this.attachResizeObserver();
|
||||
@@ -193,6 +258,9 @@ export class ReaderView {
|
||||
dispose(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
if (this.animationTimer !== null) clearTimeout(this.animationTimer);
|
||||
this.animationTimer = null;
|
||||
this.clearSpeechHighlight();
|
||||
this.releaseObjectUrl();
|
||||
this.resolver.dispose();
|
||||
this.host.remove();
|
||||
@@ -214,6 +282,7 @@ export class ReaderView {
|
||||
// any offset measured before they arrive.
|
||||
this.waitForImages().then(() => {
|
||||
this.restoreOffset();
|
||||
this.refreshSpokenChunks();
|
||||
this.emitPosition();
|
||||
});
|
||||
}
|
||||
@@ -333,6 +402,198 @@ export class ReaderView {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- read aloud ----
|
||||
|
||||
/**
|
||||
* Refreshes the speakable sentence list for the chapter on screen.
|
||||
*
|
||||
* Called after every render rather than on demand, because the caller (the TTS
|
||||
* panel) may be opened *while* a chapter is loading, and a queue built from the
|
||||
* previous chapter would highlight the wrong paragraph.
|
||||
*/
|
||||
refreshSpokenChunks(): SpokenChunk[] {
|
||||
this.spokenChunks = collectSpokenChunks(this.host.flow, (node) => this.blockIndexOf(node));
|
||||
return this.spokenChunks;
|
||||
}
|
||||
|
||||
get speechChunks(): readonly SpokenChunk[] {
|
||||
return this.spokenChunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the sentence being spoken and scrolls it into view.
|
||||
*
|
||||
* A `Range` is used instead of wrapping the sentence in a `<span>`: wrapping
|
||||
* would rewrite the publisher's markup, and it is exactly the kind of edit that
|
||||
* makes an author's stylesheet stop applying to a phrase in the middle of a
|
||||
* paragraph. A range is painted into an overlay that sits *behind* the text, so
|
||||
* line boxes do not move and pagination does not change when playback starts.
|
||||
*/
|
||||
highlightSpokenChunk(chunk: SpokenChunk | null): void {
|
||||
this.clearSpeechHighlight();
|
||||
if (!chunk || !chunk.node || !chunk.node.isConnected) return;
|
||||
const shadow = this.host.shadow;
|
||||
const range = shadow.ownerDocument.createRange();
|
||||
const end = Math.min(chunk.node.data.length, chunk.start + chunk.text.length);
|
||||
try {
|
||||
range.setStart(chunk.node, chunk.start);
|
||||
range.setEnd(chunk.node, end);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const rect = rangeRect(range);
|
||||
if (!rect) return;
|
||||
const hostRect = this.host.flow.getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) return;
|
||||
|
||||
const mark = document.createElement('div');
|
||||
mark.className = 'reader-speech-highlight';
|
||||
mark.style.cssText = [
|
||||
'position:absolute',
|
||||
`left:${rect.left - hostRect.left + this.host.flow.scrollLeft}px`,
|
||||
`top:${rect.top - hostRect.top + this.host.flow.scrollTop}px`,
|
||||
`width:${rect.width}px`,
|
||||
`height:${rect.height}px`,
|
||||
].join(';');
|
||||
// The overlay lives inside the shadow root's flow so it scrolls with the
|
||||
// text; `position: relative` on the flow is what anchors it.
|
||||
this.host.flow.append(mark);
|
||||
this.speechHighlight = mark;
|
||||
this.scrollIntoView(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first sentence inside the reader's current text selection.
|
||||
*
|
||||
* A selected paragraph is how a reader says "this bit" on a phone: tapping to
|
||||
* place a caret is not reliable over a shadow root, and long-press selection
|
||||
* already exists for copying. Reading the selection is therefore the natural
|
||||
* "read this paragraph" gesture, and it costs one call to `getSelection`.
|
||||
*/
|
||||
speechAnchorFromSelection(): SpokenChunk | null {
|
||||
// `ShadowRoot.getSelection` exists on the Chromium/WebKit that ships on
|
||||
// Android but is not in the DOM typings, hence the structural check rather
|
||||
// than a direct call the type system would reject.
|
||||
const shadowSelection = (this.host.shadow as ShadowRoot & { getSelection?: () => Selection | null }).getSelection;
|
||||
const selection = typeof shadowSelection === 'function' ? shadowSelection.call(this.host.shadow) : null;
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
for (const chunk of this.spokenChunks) {
|
||||
if (!chunk.node) continue;
|
||||
// A sentence starts inside the selection. `intersectsNode` on the sentence
|
||||
// range would also match a sentence that merely *ends* in the selection,
|
||||
// which would make a one-word selection read the preceding sentence too.
|
||||
if (range.comparePoint(chunk.node, chunk.start) === 0 || this.rangeContains(range, chunk)) return chunk;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private rangeContains(selection: Range, chunk: SpokenChunk): boolean {
|
||||
if (!chunk.node) return false;
|
||||
const start = this.host.shadow.ownerDocument.createRange();
|
||||
try {
|
||||
start.setStart(chunk.node, chunk.start);
|
||||
start.setEnd(chunk.node, Math.min(chunk.node.data.length, chunk.start + Math.max(1, chunk.text.length)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return selection.isPointInRange(start.startContainer, start.startOffset);
|
||||
}
|
||||
|
||||
/** Brings the spoken sentence back on screen, one screen at a time. */
|
||||
scrollIntoView(chunk: SpokenChunk): void {
|
||||
if (!chunk.node || !chunk.node.isConnected) return;
|
||||
const flow = this.host.flow;
|
||||
const range = this.host.shadow.ownerDocument.createRange();
|
||||
try {
|
||||
range.setStart(chunk.node, chunk.start);
|
||||
range.setEnd(chunk.node, Math.min(chunk.node.data.length, chunk.start + Math.max(1, chunk.text.length)));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const rect = rangeRect(range);
|
||||
if (!rect) return;
|
||||
if (this.settings.mode === 'paged') {
|
||||
// In paged mode the page is found by measurement, not by `scrollIntoView`,
|
||||
// because the browser's idea of "into view" is a horizontal jump to a
|
||||
// column boundary that ignores the chapter's own padding.
|
||||
const target = flow.scrollLeft + rect.left - flow.getBoundingClientRect().left - flow.clientWidth / 2;
|
||||
flow.scrollLeft = Math.max(0, target);
|
||||
return;
|
||||
}
|
||||
const hostRect = flow.getBoundingClientRect();
|
||||
if (rect.top < hostRect.top + 8 || rect.bottom > hostRect.bottom - 8) {
|
||||
flow.scrollTop += rect.top - hostRect.top - hostRect.height / 3;
|
||||
}
|
||||
}
|
||||
|
||||
clearSpeechHighlight(): void {
|
||||
this.speechHighlight?.remove();
|
||||
this.speechHighlight = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence to start reading from, for "read from here".
|
||||
*
|
||||
* Found by asking the flow which block is at the top of the reading area and
|
||||
* then taking the first sentence inside it. Measuring the viewport rather than
|
||||
* scaling the position fraction matters: in paged mode the leading edge of a
|
||||
* page is a column boundary, and a fraction of the chapter maps to a sentence
|
||||
* that can be a whole paragraph away from what the reader is looking at.
|
||||
*/
|
||||
speechAnchor(): SpokenChunk | null {
|
||||
if (this.spokenChunks.length === 0) return null;
|
||||
const flow = this.host.flow;
|
||||
const hostRect = flow.getBoundingClientRect();
|
||||
if (hostRect.width === 0 && hostRect.height === 0) return this.spokenChunks[0] ?? null;
|
||||
const edge = this.settings.mode === 'paged' ? hostRect.left + 4 : hostRect.top + 4;
|
||||
const first = this.spokenChunks[0] ?? null;
|
||||
for (const chunk of this.spokenChunks) {
|
||||
const rect = this.chunkRect(chunk);
|
||||
if (!rect) return first;
|
||||
const start = this.settings.mode === 'paged' ? rect.left : rect.top;
|
||||
if (start >= edge) return chunk;
|
||||
}
|
||||
return this.spokenChunks[this.spokenChunks.length - 1] ?? first;
|
||||
}
|
||||
|
||||
/** Bounding box of a sentence, measured through a Range rather than a wrapper. */
|
||||
private chunkRect(chunk: SpokenChunk): DOMRect | null {
|
||||
if (!chunk.node || !chunk.node.isConnected) return null;
|
||||
const range = this.host.shadow.ownerDocument.createRange();
|
||||
try {
|
||||
range.setStart(chunk.node, chunk.start);
|
||||
range.setEnd(chunk.node, Math.min(chunk.node.data.length, chunk.start + Math.max(1, chunk.text.length)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return rangeRect(range);
|
||||
}
|
||||
|
||||
/** Index of the top-level block containing a node, for sentence addressing. */
|
||||
private blockIndexOf(node: Node): number {
|
||||
const children = [...this.host.flow.children];
|
||||
let current: Node | null = node;
|
||||
while (current && current.parentNode !== this.host.flow) current = current.parentNode;
|
||||
return current ? children.indexOf(current as Element) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to a top-level block, used when resuming aloud after a chapter jump.
|
||||
*/
|
||||
revealBlock(index: number): void {
|
||||
const block = this.host.flow.children[index];
|
||||
if (!(block instanceof HTMLElement)) return;
|
||||
if (this.settings.mode === 'paged') {
|
||||
const flow = this.host.flow;
|
||||
flow.scrollLeft = Math.max(0, block.offsetLeft - flow.clientWidth / 2);
|
||||
return;
|
||||
}
|
||||
const flow = this.host.flow;
|
||||
const target = block.offsetTop - flow.clientHeight / 3;
|
||||
flow.scrollTop = Math.max(0, target);
|
||||
}
|
||||
|
||||
private objectUrl(bytes: Uint8Array, mediaType: string): string {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
@@ -378,3 +639,20 @@ export class ReaderView {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A range's client rect, or null when the host cannot measure one.
|
||||
*
|
||||
* `Range.getBoundingClientRect` is missing in jsdom and in an embedded WebView
|
||||
* whose document has no layout box yet. Returning null rather than throwing keeps
|
||||
* read-aloud working as *audio* on such a host — the highlight is an enhancement,
|
||||
* and an enhancement that takes the feature down with it is not one.
|
||||
*/
|
||||
function rangeRect(range: Range): DOMRect | null {
|
||||
if (typeof range.getBoundingClientRect !== 'function') return null;
|
||||
try {
|
||||
return range.getBoundingClientRect();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,98 @@ describe('ReaderView settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReaderView appearance settings', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
it('passes the font, margin and alignment through as inheriting variables', () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
view.applySettings({ fontFamily: 'serif', pageMargin: 2.5, textAlign: 'justify' });
|
||||
expect(container.style.getPropertyValue('--reader-font-family')).toBe('serif');
|
||||
expect(container.style.getPropertyValue('--reader-page-margin')).toBe('2.5rem');
|
||||
expect(container.style.getPropertyValue('--reader-text-align')).toBe('justify');
|
||||
});
|
||||
|
||||
it('defaults every typography control to inherit, so the book is untouched', () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
const settings = view.settingsSnapshot;
|
||||
expect(settings.fontFamily).toBe('inherit');
|
||||
expect(settings.textAlign).toBe('inherit');
|
||||
expect(settings.lineHeight).toBe('inherit');
|
||||
});
|
||||
|
||||
it('exposes brightness as a number so a dimming overlay can be computed', () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
view.applySettings({ brightness: 0.6 });
|
||||
expect(container.style.getPropertyValue('--reader-brightness')).toBe('0.6');
|
||||
});
|
||||
|
||||
it('sets a page-turn animation on the host and clears it again', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const view = make(container, reflowableDoc(2));
|
||||
view.applySettings({ pageAnimation: 'slide' });
|
||||
view.animatePage('next');
|
||||
expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBe('slide-next');
|
||||
vi.runAllTimers();
|
||||
expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not animate when the reader turned animation off', () => {
|
||||
const view = make(container, reflowableDoc(2));
|
||||
view.applySettings({ pageAnimation: 'none' });
|
||||
view.animatePage('next');
|
||||
expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBeNull();
|
||||
});
|
||||
|
||||
it('speaks in the order the reader sees, with stable block indices', async () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
await view.open(0, 0);
|
||||
const chunks = view.refreshSpokenChunks();
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
// Every sentence points at a connected text node, which is what the
|
||||
// highlight needs to paint a range.
|
||||
for (const chunk of chunks) expect(chunk.node?.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('hands back the first sentence of the chapter when nothing can be measured', async () => {
|
||||
// jsdom has no layout, so every rect is zero; the anchor must degrade to the
|
||||
// start of the chapter rather than to null, or "read from here" would do
|
||||
// nothing at all on a host without a layout engine.
|
||||
const view = make(container, reflowableDoc(1));
|
||||
await view.open(0, 0);
|
||||
const chunks = view.refreshSpokenChunks();
|
||||
expect(view.speechAnchor()).toEqual(chunks[0]);
|
||||
});
|
||||
|
||||
it('reports no anchor before a chapter has been rendered', () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
expect(view.speechAnchor()).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the speech highlight without touching the book markup', async () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
await view.open(0, 0);
|
||||
const bookContent = container.querySelector('book-content') as HTMLElement & { shadow: ShadowRoot };
|
||||
const before = bookContent.shadow.querySelector('.book-flow')?.innerHTML ?? '';
|
||||
view.highlightSpokenChunk(view.refreshSpokenChunks()[0] ?? null);
|
||||
view.clearSpeechHighlight();
|
||||
// The highlight is an overlay appended to the flow, so it *is* removed from
|
||||
// the markup when cleared; what must not survive is an edit to the book's own
|
||||
// elements, which is why the sentence is never wrapped in a span.
|
||||
expect(bookContent.shadow.querySelector('.reader-speech-highlight')).toBeNull();
|
||||
expect(bookContent.shadow.querySelector('.book-flow')?.innerHTML).toBe(before);
|
||||
expect(bookContent.shadow.innerHTML).not.toContain('<span');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReaderView resource handling', () => {
|
||||
it('releases object URLs on dispose so a long session does not leak', async () => {
|
||||
const container = document.createElement('div');
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectSpokenChunks, splitSentence, ChunkCursor } from '../src/render/tts-text.ts';
|
||||
|
||||
/**
|
||||
* Sentence chunking.
|
||||
*
|
||||
* This is the part of read-aloud that decides *what is said next*, so every
|
||||
* assertion here is really about a behaviour a reader would notice: a sentence
|
||||
* that is cut in half sounds broken, and a paragraph that is not cut at all
|
||||
* makes the synthesizer stutter or stop.
|
||||
*/
|
||||
|
||||
describe('splitSentence', () => {
|
||||
it('splits on Chinese full-width stops', () => {
|
||||
const pieces = splitSentence('他来了。她走了!然后呢?');
|
||||
expect(pieces.map((piece) => piece.text)).toEqual(['他来了。', '她走了!', '然后呢?']);
|
||||
});
|
||||
|
||||
it('keeps the offset of every piece inside the original node', () => {
|
||||
const text = '第一句。第二句。';
|
||||
for (const piece of splitSentence(text)) {
|
||||
expect(text.slice(piece.start, piece.start + piece.text.length)).toBe(piece.text);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not split a decimal number', () => {
|
||||
expect(splitSentence('圆周率是 3.14 左右。')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not split an abbreviation', () => {
|
||||
const pieces = splitSentence('Dr. Smith arrived. He was late.');
|
||||
// The trailing space belongs to the sentence being spoken only for tidiness;
|
||||
// what matters is that "Dr." was not cut off from the name that follows it.
|
||||
expect(pieces.map((piece) => piece.text.trim())).toEqual(['Dr. Smith arrived.', 'He was late.']);
|
||||
});
|
||||
|
||||
it('keeps a closing quote with the sentence it closes', () => {
|
||||
const pieces = splitSentence('他说:“好的。”然后走了。');
|
||||
expect(pieces.map((piece) => piece.text)).toEqual(['他说:“好的。”', '然后走了。']);
|
||||
});
|
||||
|
||||
it('treats a run of terminators as one ending', () => {
|
||||
expect(splitSentence('什么?!').map((piece) => piece.text)).toEqual(['什么?!']);
|
||||
});
|
||||
|
||||
it('cuts an unpunctuated run at the ceiling instead of emitting one huge utterance', () => {
|
||||
const long = 'あ'.repeat(1000);
|
||||
const pieces = splitSentence(long, 300);
|
||||
expect(pieces.length).toBeGreaterThan(1);
|
||||
for (const piece of pieces) expect(piece.text.length).toBeLessThanOrEqual(300);
|
||||
expect(pieces.map((piece) => piece.text).join('')).toBe(long);
|
||||
});
|
||||
|
||||
it('drops whitespace-only pieces rather than speaking silence', () => {
|
||||
expect(splitSentence(' \n ')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectSpokenChunks', () => {
|
||||
function chunksFor(html: string): ReturnType<typeof collectSpokenChunks> {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = html;
|
||||
document.body.append(root);
|
||||
return collectSpokenChunks(root, (node) => {
|
||||
const block = [...root.children].find((child) => child.contains(node));
|
||||
return block ? [...root.children].indexOf(block) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
it('walks paragraphs in document order', () => {
|
||||
const chunks = chunksFor('<p>第一段。</p><p>第二段。</p>');
|
||||
expect(chunks.map((chunk) => chunk.text)).toEqual(['第一段。', '第二段。']);
|
||||
});
|
||||
|
||||
it('reports the block index each sentence belongs to', () => {
|
||||
const chunks = chunksFor('<p>甲。乙。</p><p>丙。</p>');
|
||||
expect(chunks.map((chunk) => chunk.blockIndex)).toEqual([0, 0, 1]);
|
||||
});
|
||||
|
||||
it('skips ruby annotations, which are not read on their own', () => {
|
||||
// A ruby base and the text after it are separate text nodes, so they come
|
||||
// back as separate pieces. What matters is that the reading (かん) is never
|
||||
// spoken, which is the behaviour a reader would notice immediately.
|
||||
const chunks = chunksFor('<p><ruby>漢<rt>かん</rt></ruby>字。</p>');
|
||||
expect(chunks.map((chunk) => chunk.text).join('')).toBe('漢字。');
|
||||
expect(chunks.some((chunk) => chunk.text.includes('かん'))).toBe(false);
|
||||
});
|
||||
|
||||
it('skips code blocks', () => {
|
||||
expect(chunksFor('<pre>const a = 1;</pre>')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('points each sentence at the text node it came from', () => {
|
||||
const chunks = chunksFor('<p>一句话。</p>');
|
||||
expect(chunks[0]?.node?.data).toBe('一句话。');
|
||||
expect(chunks[0]?.start).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChunkCursor', () => {
|
||||
const chunks = ['a', 'b', 'c'].map((text, index) => ({
|
||||
text,
|
||||
node: null,
|
||||
start: 0,
|
||||
blockIndex: index,
|
||||
}));
|
||||
|
||||
it('advances and stops at the end instead of wrapping', () => {
|
||||
const cursor = new ChunkCursor();
|
||||
cursor.reset(chunks, 0);
|
||||
expect(cursor.current?.text).toBe('a');
|
||||
expect(cursor.next()?.text).toBe('b');
|
||||
expect(cursor.next()?.text).toBe('c');
|
||||
expect(cursor.next()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not move before the first chunk', () => {
|
||||
const cursor = new ChunkCursor();
|
||||
cursor.reset(chunks, 0);
|
||||
expect(cursor.previous()).toBeNull();
|
||||
expect(cursor.index).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps a reset index past the end', () => {
|
||||
const cursor = new ChunkCursor();
|
||||
cursor.reset(chunks, 99);
|
||||
expect(cursor.index).toBe(2);
|
||||
});
|
||||
|
||||
it('reports no current chunk for an empty queue', () => {
|
||||
const cursor = new ChunkCursor();
|
||||
cursor.reset([], 0);
|
||||
expect(cursor.current).toBeNull();
|
||||
expect(cursor.next()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TtsEngine } from '../src/render/tts.ts';
|
||||
import type { SpokenChunk } from '../src/render/tts-text.ts';
|
||||
|
||||
/**
|
||||
* The read-aloud engine's sequencing.
|
||||
*
|
||||
* `speechSynthesis` is a fake here on purpose: the interesting behaviour is not
|
||||
* "does the browser speak" but "does the queue advance exactly once per finished
|
||||
* sentence, and does a stop really stop". Those are the two failures a reader
|
||||
* notices — a skipped sentence and a voice that keeps talking after pause.
|
||||
*/
|
||||
|
||||
class FakeUtterance {
|
||||
text = '';
|
||||
rate = 1;
|
||||
pitch = 1;
|
||||
volume = 1;
|
||||
voice: SpeechSynthesisVoice | null = null;
|
||||
lang = '';
|
||||
onend: (() => void) | null = null;
|
||||
onerror: ((event: { error: string }) => void) | null = null;
|
||||
}
|
||||
|
||||
class FakeSynthesis {
|
||||
spoken: FakeUtterance[] = [];
|
||||
cancel = vi.fn();
|
||||
pause = vi.fn();
|
||||
resume = vi.fn();
|
||||
paused = false;
|
||||
speak(utterance: FakeUtterance): void {
|
||||
this.spoken.push(utterance);
|
||||
}
|
||||
getVoices(): SpeechSynthesisVoice[] {
|
||||
return [
|
||||
{ voiceURI: 'zh-1', name: '中文', lang: 'zh-CN', default: true },
|
||||
{ voiceURI: 'en-1', name: 'English', lang: 'en-US', default: false },
|
||||
] as unknown as SpeechSynthesisVoice[];
|
||||
}
|
||||
addEventListener(): void {}
|
||||
}
|
||||
|
||||
function chunk(text: string, index = 0): SpokenChunk {
|
||||
return { text, node: null, start: 0, blockIndex: index };
|
||||
}
|
||||
|
||||
function makeEngine(synthesis: FakeSynthesis, queue: SpokenChunk[][]) {
|
||||
let round = 0;
|
||||
const seen: string[] = [];
|
||||
const engine = new TtsEngine({
|
||||
synthesis: synthesis as unknown as SpeechSynthesis,
|
||||
utteranceFactory: () => new FakeUtterance() as unknown as SpeechSynthesisUtterance,
|
||||
// The watchdog is not under test and a real timer would leak between tests.
|
||||
setTimeout: () => 0 as unknown as ReturnType<typeof setTimeout>,
|
||||
clearTimeout: () => undefined,
|
||||
loadQueue: async () => {
|
||||
const chunks = queue[Math.min(round, queue.length - 1)] ?? [];
|
||||
round += 1;
|
||||
return { chunks, startIndex: 0 };
|
||||
},
|
||||
onChunk: (spoken) => seen.push(spoken.text),
|
||||
onState: () => undefined,
|
||||
});
|
||||
return { engine, seen };
|
||||
}
|
||||
|
||||
describe('TtsEngine', () => {
|
||||
it('speaks one sentence at a time and advances only on the engine callback', async () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine, seen } = makeEngine(synthesis, [[chunk('第一句。'), chunk('第二句。')]]);
|
||||
|
||||
await engine.play();
|
||||
expect(seen).toEqual(['第一句。']);
|
||||
expect(synthesis.spoken).toHaveLength(1);
|
||||
|
||||
// Nothing advances until the engine says the sentence finished.
|
||||
expect(seen).toEqual(['第一句。']);
|
||||
synthesis.spoken[0]!.onend?.();
|
||||
expect(seen).toEqual(['第一句。', '第二句。']);
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('reports the sentence index and total so the toolbar can show progress', async () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine } = makeEngine(synthesis, [[chunk('一。'), chunk('二。'), chunk('三。')]]);
|
||||
await engine.play();
|
||||
expect(engine.snapshot.index).toBe(0);
|
||||
expect(engine.snapshot.total).toBe(3);
|
||||
synthesis.spoken[0]!.onend?.();
|
||||
expect(engine.snapshot.index).toBe(1);
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('stops speaking and clears the queue on stop', async () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine, seen } = makeEngine(synthesis, [[chunk('一。'), chunk('二。')]]);
|
||||
await engine.play();
|
||||
synthesis.spoken[0]!.onend?.();
|
||||
engine.stop();
|
||||
// A late callback from a cancelled utterance must not restart playback.
|
||||
synthesis.spoken[1]?.onend?.();
|
||||
expect(seen).toEqual(['一。', '二。']);
|
||||
expect(engine.snapshot.state).toBe('idle');
|
||||
expect(engine.snapshot.total).toBe(0);
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('does not treat a cancellation as an error', async () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine } = makeEngine(synthesis, [[chunk('一。')]]);
|
||||
await engine.play();
|
||||
synthesis.spoken[0]!.onerror?.({ error: 'canceled' });
|
||||
expect(engine.snapshot.error).toBe('');
|
||||
expect(engine.snapshot.state).toBe('playing');
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('surfaces a real engine error instead of playing on', async () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine } = makeEngine(synthesis, [[chunk('一。')]]);
|
||||
await engine.play();
|
||||
synthesis.spoken[0]!.onerror?.({ error: 'synthesis-failed' });
|
||||
expect(engine.snapshot.state).toBe('idle');
|
||||
expect(engine.snapshot.error).toContain('synthesis-failed');
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('continues into the next queue when the current one runs out', async () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine, seen } = makeEngine(synthesis, [[chunk('一。')], [chunk('二。')]]);
|
||||
await engine.play();
|
||||
synthesis.spoken[0]!.onend?.();
|
||||
// The second sentence comes from the *next* queue, which is what a chapter
|
||||
// transition looks like from the engine's point of view.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(seen).toEqual(['一。', '二。']);
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('passes the reader’s rate, pitch and volume to each utterance', async () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine } = makeEngine(synthesis, [[chunk('一。')]]);
|
||||
engine.setRate(1.5);
|
||||
engine.setPitch(0.8);
|
||||
engine.setVolume(0.5);
|
||||
await engine.play();
|
||||
const utterance = synthesis.spoken[synthesis.spoken.length - 1]!;
|
||||
expect(utterance.rate).toBe(1.5);
|
||||
expect(utterance.pitch).toBe(0.8);
|
||||
expect(utterance.volume).toBe(0.5);
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('clamps out-of-range values rather than handing them to the engine', () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine } = makeEngine(synthesis, [[]]);
|
||||
engine.setRate(99);
|
||||
engine.setVolume(-4);
|
||||
expect(engine.snapshot.rate).toBe(3);
|
||||
expect(engine.snapshot.volume).toBe(0);
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('reports idle and an explanation when the chapter has nothing to say', async () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine } = makeEngine(synthesis, [[]]);
|
||||
await engine.play();
|
||||
expect(engine.snapshot.state).toBe('idle');
|
||||
expect(engine.snapshot.error).toBe('这一章没有可朗读的文字');
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('reports unsupported instead of throwing when there is no synthesis', async () => {
|
||||
const engine = new TtsEngine({
|
||||
synthesis: null,
|
||||
loadQueue: async () => ({ chunks: [chunk('一。')], startIndex: 0 }),
|
||||
onChunk: () => undefined,
|
||||
onState: () => undefined,
|
||||
});
|
||||
await engine.play();
|
||||
expect(engine.snapshot.state).toBe('unsupported');
|
||||
expect(engine.snapshot.error).toContain('不支持');
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('exposes the device voice list, which is what a picker renders', () => {
|
||||
const synthesis = new FakeSynthesis();
|
||||
const { engine } = makeEngine(synthesis, [[]]);
|
||||
expect(engine.snapshot.voices.map((voice) => voice.id)).toEqual(['zh-1', 'en-1']);
|
||||
engine.dispose();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user