feat(web): 丰富阅读界面设置并支持系统 TTS 朗读

This commit is contained in:
cnb.dQC5J6ohAGA committed 2026-09-16 22:55:58 +08:00
1 parent 88ab67fcde
commit 058bee57a5
11 files changed
+2279 -29

No files matched your search

+52
View File
@@ -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` 快速判断,再用内容哈希确认。绝大多数文件在第一阶段就被跳过。
+67
View File
@@ -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 节点**上,绕过这个方法的翻页会让引擎对着一个已经脱离文档的节点
继续朗读——听起来就像客户端在自言自语。
+261
View File
@@ -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;
}
}
+449
View File
@@ -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));
}
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+278
View File
@@ -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;
}
}
+92
View File
@@ -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');
+137
View File
@@ -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();
});
});
+194
View File
@@ -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();
});
});