diff --git a/README.md b/README.md index 7904429..01e263a 100644 --- a/README.md +++ b/README.md @@ -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` 快速判断,再用内容哈希确认。绝大多数文件在第一阶段就被跳过。 diff --git a/docs/architecture.md b/docs/architecture.md index 45e4a29..810bc3d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 + +把正在朗读的句子包进 `` 是最直觉的做法,也是最糟的:它改写了出版方的标记, +会让作者针对 `p > span` 的样式失效,并且因为行盒度量变化而**重新分页**—— +读者一按朗读,页面就跳。所以高亮是 flow 里的一个绝对定位覆盖层, +位置来自 `Range.getBoundingClientRect()`,文字本身一个字节都没动。 + +代价是高亮必须自己跟随滚动、自己处理换行(一个跨行的句子只有一个矩形), +这比包 `` 麻烦,但换来的是「按朗读不会动版」。 + +### 朗读队列与章节的边界 + +引擎只知道一件事:给我一批句子,说我读到哪里了。章节的事由屏幕层负责—— +队列耗尽时引擎再问一次,屏幕层决定是「取下一章」还是「到此为止」。 +这个分工让「读到章末停下」成为设置项而不是引擎里的分支。 + +屏幕层还有一个必须走的公共路径:所有翻页都经过 `turnPage()`。原因是引擎锚定在 +**当前章节的 DOM 节点**上,绕过这个方法的翻页会让引擎对着一个已经脱离文档的节点 +继续朗读——听起来就像客户端在自言自语。 diff --git a/web/src/render/tts-text.ts b/web/src/render/tts-text.ts new file mode 100644 index 0000000..f825786 --- /dev/null +++ b/web/src/render/tts-text.ts @@ -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 `` — 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; + } +} diff --git a/web/src/render/tts.ts b/web/src/render/tts.ts new file mode 100644 index 0000000..573ade2 --- /dev/null +++ b/web/src/render/tts.ts @@ -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; + clearTimeout?: (handle: ReturnType) => 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 | 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 { + 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 { + if (this.queue.length === 0) return; + await this.jump(this.cursor + 1); + } + + async previous(): Promise { + 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 { + 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)); +} diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index afb9b5a..86c1609 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -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 { diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 420a6e2..607d0eb 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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 `` — 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 { diff --git a/web/src/ui/reader-screen.ts b/web/src/ui/reader-screen.ts index 5ee1d2d..10ba452 100644 --- a/web/src/ui/reader-screen.ts +++ b/web/src/ui/reader-screen.ts @@ -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 { + 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 { 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('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 { @@ -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('[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 { + 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): 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; diff --git a/web/src/ui/reader-view.ts b/web/src/ui/reader-view.ts index 4c6d569..74532b3 100644 --- a/web/src/ui/reader-view.ts +++ b/web/src/ui/reader-view.ts @@ -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 | 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): 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 ``: 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; + } +} diff --git a/web/test/reader-view.test.ts b/web/test/reader-view.test.ts index 698b57a..4f92d03 100644 --- a/web/test/reader-view.test.ts +++ b/web/test/reader-view.test.ts @@ -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(' { it('releases object URLs on dispose so a long session does not leak', async () => { const container = document.createElement('div'); diff --git a/web/test/tts-text.test.ts b/web/test/tts-text.test.ts new file mode 100644 index 0000000..259f841 --- /dev/null +++ b/web/test/tts-text.test.ts @@ -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 { + 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('

第一段。

第二段。

'); + expect(chunks.map((chunk) => chunk.text)).toEqual(['第一段。', '第二段。']); + }); + + it('reports the block index each sentence belongs to', () => { + const chunks = chunksFor('

甲。乙。

丙。

'); + 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('

漢かん字。

'); + expect(chunks.map((chunk) => chunk.text).join('')).toBe('漢字。'); + expect(chunks.some((chunk) => chunk.text.includes('かん'))).toBe(false); + }); + + it('skips code blocks', () => { + expect(chunksFor('
const a = 1;
')).toHaveLength(0); + }); + + it('points each sentence at the text node it came from', () => { + const chunks = chunksFor('

一句话。

'); + 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(); + }); +}); diff --git a/web/test/tts.test.ts b/web/test/tts.test.ts new file mode 100644 index 0000000..d5425c5 --- /dev/null +++ b/web/test/tts.test.ts @@ -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, + 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(); + }); +});