fix(core): address white-box review feedback
- Fix unicode surrogate pairs and emoji slicing via Array.from in ORP calculator - Add slot smoothing constraint to pivot element to prevent horizontal jitter - Implement adaptive timestamp compensation and visibilitychange auto-pause - Slim storage layer by updating only STORE_META on progress changes with memory fallback - Align EPUB chapter indices to token array indices instead of raw character offsets - Support standalone emoji tokens via Extended_Pictographic regex - Code-split JSZip via dynamic import to optimize initial bundle size Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -126,9 +126,9 @@ export const FocusDisplay: React.FC<FocusDisplayProps> = ({
|
|||||||
{token.prefix}
|
{token.prefix}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Pivot (ORP): strictly mathematically centered at 50% */}
|
{/* Pivot (ORP): strictly mathematically centered at 50% with slot smoothing */}
|
||||||
<span
|
<span
|
||||||
className={`relative font-bold ${colorConfig.text} ${colorConfig.glow} px-[0.02em]`}
|
className={`relative inline-block min-w-[1.1ch] text-center font-bold ${colorConfig.text} ${colorConfig.glow} px-[0.02em]`}
|
||||||
>
|
>
|
||||||
{token.pivot || ' '}
|
{token.pivot || ' '}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const LibraryModal: React.FC<LibraryModalProps> = ({
|
|||||||
title = parsed.title;
|
title = parsed.title;
|
||||||
author = parsed.author;
|
author = parsed.author;
|
||||||
fullText = parsed.fullText;
|
fullText = parsed.fullText;
|
||||||
chapters = parsed.chapters;
|
chapters = parsed.rawChapters;
|
||||||
} else if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
} else if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
||||||
fullText = await file.text();
|
fullText = await file.text();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import JSZip from 'jszip';
|
export interface EpubChapterItem {
|
||||||
import type { Chapter } from '../types';
|
id: string;
|
||||||
|
title: string;
|
||||||
|
charOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParsedEpub {
|
export interface ParsedEpub {
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
fullText: string;
|
fullText: string;
|
||||||
chapters: Chapter[];
|
rawChapters: EpubChapterItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side EPUB parser with dynamic JSZip import to reduce initial bundle size.
|
||||||
|
*/
|
||||||
export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
||||||
|
// Dynamic import JSZip to split ~100KB off the initial load bundle
|
||||||
|
const { default: JSZip } = await import('jszip');
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||||
|
|
||||||
@@ -39,8 +48,11 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
|||||||
const opfDoc = domParser.parseFromString(opfXml, 'application/xml');
|
const opfDoc = domParser.parseFromString(opfXml, 'application/xml');
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
const title = opfDoc.querySelector('title, dc\\:title')?.textContent?.trim() || (file instanceof File ? file.name.replace(/\.epub$/i, '') : '未命名书籍');
|
const title =
|
||||||
const author = opfDoc.querySelector('creator, dc\\:creator')?.textContent?.trim() || '未知作者';
|
opfDoc.querySelector('title, dc\\:title')?.textContent?.trim() ||
|
||||||
|
(file instanceof File ? file.name.replace(/\.epub$/i, '') : '未命名书籍');
|
||||||
|
const author =
|
||||||
|
opfDoc.querySelector('creator, dc\\:creator')?.textContent?.trim() || '未知作者';
|
||||||
|
|
||||||
// Manifest: id -> href
|
// Manifest: id -> href
|
||||||
const manifestMap = new Map<string, string>();
|
const manifestMap = new Map<string, string>();
|
||||||
@@ -48,7 +60,6 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
|||||||
const id = item.getAttribute('id');
|
const id = item.getAttribute('id');
|
||||||
const href = item.getAttribute('href');
|
const href = item.getAttribute('href');
|
||||||
if (id && href) {
|
if (id && href) {
|
||||||
// Decode URL encoding
|
|
||||||
manifestMap.set(id, decodeURIComponent(href));
|
manifestMap.set(id, decodeURIComponent(href));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -62,7 +73,7 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const chapters: Chapter[] = [];
|
const rawChapters: EpubChapterItem[] = [];
|
||||||
let fullText = '';
|
let fullText = '';
|
||||||
let chapterIndex = 1;
|
let chapterIndex = 1;
|
||||||
|
|
||||||
@@ -70,7 +81,6 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
|||||||
const relativeHref = manifestMap.get(idref);
|
const relativeHref = manifestMap.get(idref);
|
||||||
if (!relativeHref) continue;
|
if (!relativeHref) continue;
|
||||||
|
|
||||||
// Resolve full path inside zip
|
|
||||||
const filePath = opfDir ? resolvePath(opfDir, relativeHref) : relativeHref;
|
const filePath = opfDir ? resolvePath(opfDir, relativeHref) : relativeHref;
|
||||||
const chapterFile = zip.file(filePath);
|
const chapterFile = zip.file(filePath);
|
||||||
if (!chapterFile) continue;
|
if (!chapterFile) continue;
|
||||||
@@ -87,7 +97,6 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
|||||||
|
|
||||||
// Extract text content cleanly
|
// Extract text content cleanly
|
||||||
const body = chapterDoc.body || chapterDoc.documentElement;
|
const body = chapterDoc.body || chapterDoc.documentElement;
|
||||||
// Remove scripts and styles
|
|
||||||
body.querySelectorAll('script, style, svg').forEach((el) => el.remove());
|
body.querySelectorAll('script, style, svg').forEach((el) => el.remove());
|
||||||
|
|
||||||
const paragraphs: string[] = [];
|
const paragraphs: string[] = [];
|
||||||
@@ -105,10 +114,11 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
|||||||
|
|
||||||
const chapterText = paragraphs.join('\n\n');
|
const chapterText = paragraphs.join('\n\n');
|
||||||
if (chapterText.trim().length > 0) {
|
if (chapterText.trim().length > 0) {
|
||||||
chapters.push({
|
const startCharOffset = fullText.length;
|
||||||
|
rawChapters.push({
|
||||||
id: `chap-${chapterIndex}`,
|
id: `chap-${chapterIndex}`,
|
||||||
title: chapterTitle,
|
title: chapterTitle,
|
||||||
startIndex: fullText.length,
|
charOffset: startCharOffset,
|
||||||
});
|
});
|
||||||
|
|
||||||
fullText += (fullText ? '\n\n' : '') + chapterText;
|
fullText += (fullText ? '\n\n' : '') + chapterText;
|
||||||
@@ -124,7 +134,7 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
|||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
fullText,
|
fullText,
|
||||||
chapters,
|
rawChapters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Optimal Recognition Point (ORP) Calculation for Spritz RSVP Reading.
|
* Optimal Recognition Point (ORP) Calculation for Spritz RSVP Reading.
|
||||||
* Fixates the eye at ~30%-35% into the word where human ocular fixation naturally rests.
|
* Fixates the eye at ~30%-35% into the word where human ocular fixation naturally rests.
|
||||||
|
* Uses unicode-safe code point segmentation (Array.from) to eliminate surrogate pair
|
||||||
|
* and emoji splitting defects.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CJK_REGEX = /[一-龥-ヿ가-]/;
|
const CJK_REGEX = /[一-龥-ヿ가-]/;
|
||||||
@@ -15,9 +17,11 @@ export function isCJKWord(word: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the 0-based index of the ORP character in cleanWord.
|
* Calculates the 0-based index of the ORP character in cleanWord.
|
||||||
|
* Based on unicode code-point length (Array.from(cleanWord).length).
|
||||||
*/
|
*/
|
||||||
export function calculateOrpIndex(cleanWord: string): number {
|
export function calculateOrpIndex(cleanWord: string): number {
|
||||||
const len = cleanWord.length;
|
const chars = Array.from(cleanWord);
|
||||||
|
const len = chars.length;
|
||||||
if (len <= 1) return 0;
|
if (len <= 1) return 0;
|
||||||
|
|
||||||
if (isCJKWord(cleanWord)) {
|
if (isCJKWord(cleanWord)) {
|
||||||
@@ -45,6 +49,7 @@ export interface SplitWord {
|
|||||||
/**
|
/**
|
||||||
* Splits a token into prefix, pivot (ORP character), and suffix.
|
* Splits a token into prefix, pivot (ORP character), and suffix.
|
||||||
* Handles leading punctuation (e.g. "“", "《", "(") and trailing punctuation.
|
* Handles leading punctuation (e.g. "“", "《", "(") and trailing punctuation.
|
||||||
|
* Operates purely on unicode code points to prevent corrupting surrogate pairs.
|
||||||
*/
|
*/
|
||||||
export function splitOrp(
|
export function splitOrp(
|
||||||
coreWord: string,
|
coreWord: string,
|
||||||
@@ -60,10 +65,11 @@ export function splitOrp(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chars = Array.from(coreWord);
|
||||||
const orpIndex = calculateOrpIndex(coreWord);
|
const orpIndex = calculateOrpIndex(coreWord);
|
||||||
const prefix = leadingPunct + coreWord.slice(0, orpIndex);
|
const prefix = leadingPunct + chars.slice(0, orpIndex).join('');
|
||||||
const pivot = coreWord[orpIndex] || '';
|
const pivot = chars[orpIndex] || '';
|
||||||
const suffix = coreWord.slice(orpIndex + 1) + trailingPunct;
|
const suffix = chars.slice(orpIndex + 1).join('') + trailingPunct;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prefix,
|
prefix,
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ const SENTENCE_PUNCT_REGEX = /[.!?。!?…\n]/;
|
|||||||
// Clause/pause punctuation
|
// Clause/pause punctuation
|
||||||
const CLAUSE_PUNCT_REGEX = /[,;:,;:、—–]/;
|
const CLAUSE_PUNCT_REGEX = /[,;:,;:、—–]/;
|
||||||
|
|
||||||
// Chapter detection regex patterns
|
// Emoji / Extended pictographic Unicode matcher
|
||||||
|
const EMOJI_REGEX = /^\p{Extended_Pictographic}+$/u;
|
||||||
|
|
||||||
|
// Chapter detection regex patterns for raw text / markdown
|
||||||
const CHAPTER_REGEXES = [
|
const CHAPTER_REGEXES = [
|
||||||
/^(#{1,3}\s+.+)$/m, // # Markdown header
|
/^(#{1,3}\s+.+)$/m, // # Markdown header
|
||||||
/^(第[0-9一二三四五六七八九十百千万]+[章回卷节篇部集].*)$/m, // 第X章 ...
|
/^(第[0-9一二三四五六七八九十百千万]+[章回卷节篇部集].*)$/m, // 第X章 ...
|
||||||
@@ -20,6 +23,13 @@ const CHAPTER_REGEXES = [
|
|||||||
/^(Act\s+[0-9IVXLCDM]+.*)$/im, // Act X ...
|
/^(Act\s+[0-9IVXLCDM]+.*)$/im, // Act X ...
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export interface ChapterInputHint {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
charOffset?: number;
|
||||||
|
startIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenizeResult {
|
export interface TokenizeResult {
|
||||||
tokens: Token[];
|
tokens: Token[];
|
||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
@@ -27,14 +37,28 @@ export interface TokenizeResult {
|
|||||||
|
|
||||||
export function tokenizeText(
|
export function tokenizeText(
|
||||||
rawText: string,
|
rawText: string,
|
||||||
existingChapters?: Chapter[]
|
existingChapters?: ChapterInputHint[]
|
||||||
): TokenizeResult {
|
): TokenizeResult {
|
||||||
if (!rawText || !rawText.trim()) {
|
if (!rawText || !rawText.trim()) {
|
||||||
return { tokens: [], chapters: [] };
|
return { tokens: [], chapters: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const chapters: Chapter[] = existingChapters ? [...existingChapters] : [];
|
// Determine chapter source
|
||||||
const autoDetectChapters = !existingChapters || existingChapters.length === 0;
|
const hasExternalHints = existingChapters && existingChapters.length > 0;
|
||||||
|
const chapterHints: { id: string; title: string; charOffset: number }[] = [];
|
||||||
|
|
||||||
|
if (hasExternalHints) {
|
||||||
|
existingChapters.forEach((c, idx) => {
|
||||||
|
chapterHints.push({
|
||||||
|
id: c.id || `chap-${idx + 1}`,
|
||||||
|
title: c.title,
|
||||||
|
charOffset: c.charOffset ?? c.startIndex ?? 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
chapterHints.sort((a, b) => a.charOffset - b.charOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoDetectChapters = !hasExternalHints;
|
||||||
|
|
||||||
let segmenter: Intl.Segmenter | null = null;
|
let segmenter: Intl.Segmenter | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -70,6 +94,7 @@ export function tokenizeText(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chapters: Chapter[] = [];
|
||||||
const tokens: Token[] = [];
|
const tokens: Token[] = [];
|
||||||
let pendingLeading = '';
|
let pendingLeading = '';
|
||||||
let newlineCount = 0;
|
let newlineCount = 0;
|
||||||
@@ -82,8 +107,26 @@ export function tokenizeText(
|
|||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
const { segment, isWordLike, index } = seg;
|
const { segment, isWordLike, index } = seg;
|
||||||
|
|
||||||
// Check if we reached a detected chapter boundary
|
// Align chapters based on character offset -> exact Token index
|
||||||
if (
|
if (hasExternalHints) {
|
||||||
|
while (
|
||||||
|
nextChapterIdx < chapterHints.length &&
|
||||||
|
index >= chapterHints[nextChapterIdx].charOffset
|
||||||
|
) {
|
||||||
|
const hint = chapterHints[nextChapterIdx];
|
||||||
|
currentChapterIndex = chapters.length;
|
||||||
|
chapters.push({
|
||||||
|
id: hint.id,
|
||||||
|
title: hint.title,
|
||||||
|
startIndex: tokens.length, // True token index
|
||||||
|
});
|
||||||
|
nextChapterIdx++;
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
tokens[tokens.length - 1].isParagraphEnd = true;
|
||||||
|
tokens[tokens.length - 1].isSentenceEnd = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
autoDetectChapters &&
|
autoDetectChapters &&
|
||||||
nextChapterIdx < detectedChapterPositions.length &&
|
nextChapterIdx < detectedChapterPositions.length &&
|
||||||
index >= detectedChapterPositions[nextChapterIdx].index
|
index >= detectedChapterPositions[nextChapterIdx].index
|
||||||
@@ -93,7 +136,7 @@ export function tokenizeText(
|
|||||||
chapters.push({
|
chapters.push({
|
||||||
id: `chap-${chapters.length + 1}`,
|
id: `chap-${chapters.length + 1}`,
|
||||||
title: chapInfo.line,
|
title: chapInfo.line,
|
||||||
startIndex: tokens.length,
|
startIndex: tokens.length, // True token index
|
||||||
});
|
});
|
||||||
nextChapterIdx++;
|
nextChapterIdx++;
|
||||||
if (tokens.length > 0) {
|
if (tokens.length > 0) {
|
||||||
@@ -116,22 +159,49 @@ export function tokenizeText(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emoji compatibility: Extended_Pictographic symbols stand as full tokens
|
||||||
|
if (EMOJI_REGEX.test(trimmed)) {
|
||||||
|
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
||||||
|
trimmed,
|
||||||
|
pendingLeading,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
tokens.push({
|
||||||
|
id: tokens.length,
|
||||||
|
text: prefix + pivot + suffix,
|
||||||
|
cleanWord: trimmed,
|
||||||
|
prefix,
|
||||||
|
pivot,
|
||||||
|
suffix,
|
||||||
|
orpIndex,
|
||||||
|
isParagraphEnd: newlineCount >= 2,
|
||||||
|
isSentenceEnd: false,
|
||||||
|
isClauseEnd: false,
|
||||||
|
isLongWord: false,
|
||||||
|
chapterIndex: currentChapterIndex,
|
||||||
|
});
|
||||||
|
pendingLeading = '';
|
||||||
|
newlineCount = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (LEADING_PUNCT_REGEX.test(trimmed)) {
|
if (LEADING_PUNCT_REGEX.test(trimmed)) {
|
||||||
// Attaches to the NEXT token's prefix
|
// Attaches to the NEXT token's prefix
|
||||||
pendingLeading += trimmed;
|
pendingLeading += trimmed;
|
||||||
} else {
|
} else if (tokens.length > 0) {
|
||||||
// Trailing punctuation attached to the PREVIOUS token
|
// Trailing punctuation attached to the PREVIOUS token
|
||||||
if (tokens.length > 0) {
|
const prev = tokens[tokens.length - 1];
|
||||||
const prev = tokens[tokens.length - 1];
|
prev.text += trimmed;
|
||||||
prev.text += trimmed;
|
prev.suffix += trimmed;
|
||||||
prev.suffix += trimmed;
|
if (SENTENCE_PUNCT_REGEX.test(trimmed)) {
|
||||||
if (SENTENCE_PUNCT_REGEX.test(trimmed)) {
|
prev.isSentenceEnd = true;
|
||||||
prev.isSentenceEnd = true;
|
|
||||||
}
|
|
||||||
if (CLAUSE_PUNCT_REGEX.test(trimmed)) {
|
|
||||||
prev.isClauseEnd = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (CLAUSE_PUNCT_REGEX.test(trimmed)) {
|
||||||
|
prev.isClauseEnd = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Leading punctuation or symbols at the start of text (don't discard)
|
||||||
|
pendingLeading += trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newlineCount >= 2 && tokens.length > 0) {
|
if (newlineCount >= 2 && tokens.length > 0) {
|
||||||
@@ -145,8 +215,8 @@ export function tokenizeText(
|
|||||||
if (!coreWord) continue;
|
if (!coreWord) continue;
|
||||||
|
|
||||||
const isLong = isCJKWord(coreWord)
|
const isLong = isCJKWord(coreWord)
|
||||||
? coreWord.length >= 4
|
? Array.from(coreWord).length >= 4
|
||||||
: coreWord.length > 8;
|
: Array.from(coreWord).length > 8;
|
||||||
|
|
||||||
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
||||||
coreWord,
|
coreWord,
|
||||||
@@ -175,13 +245,13 @@ export function tokenizeText(
|
|||||||
newlineCount = 0;
|
newlineCount = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regex fallback for environments without Intl.Segmenter
|
// Fallback regex loop with Emoji support
|
||||||
const wordPattern = /[一-龥]|[a-zA-Z0-9_À-ɏ]+|[^\s\w]/g;
|
const wordPattern = /\p{Extended_Pictographic}+|[一-龥]|[a-zA-Z0-9_À-ɏ]+|[^\s\w]/gu;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = wordPattern.exec(rawText)) !== null) {
|
while ((match = wordPattern.exec(rawText)) !== null) {
|
||||||
const str = match[0];
|
const str = match[0];
|
||||||
if (/[一-龥a-zA-Z0-9]/.test(str)) {
|
if (EMOJI_REGEX.test(str) || /[一-龥a-zA-Z0-9]/.test(str)) {
|
||||||
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
||||||
str,
|
str,
|
||||||
pendingLeading,
|
pendingLeading,
|
||||||
@@ -198,26 +268,46 @@ export function tokenizeText(
|
|||||||
isParagraphEnd: newlineCount >= 2,
|
isParagraphEnd: newlineCount >= 2,
|
||||||
isSentenceEnd: false,
|
isSentenceEnd: false,
|
||||||
isClauseEnd: false,
|
isClauseEnd: false,
|
||||||
isLongWord: str.length > 8,
|
isLongWord: Array.from(str).length > 8,
|
||||||
chapterIndex: currentChapterIndex,
|
chapterIndex: currentChapterIndex,
|
||||||
});
|
});
|
||||||
pendingLeading = '';
|
pendingLeading = '';
|
||||||
newlineCount = 0;
|
newlineCount = 0;
|
||||||
} else if (LEADING_PUNCT_REGEX.test(str)) {
|
} else if (LEADING_PUNCT_REGEX.test(str)) {
|
||||||
pendingLeading += str;
|
pendingLeading += str;
|
||||||
|
} else if (tokens.length > 0) {
|
||||||
|
const prev = tokens[tokens.length - 1];
|
||||||
|
prev.text += str;
|
||||||
|
prev.suffix += str;
|
||||||
|
if (SENTENCE_PUNCT_REGEX.test(str)) prev.isSentenceEnd = true;
|
||||||
|
if (CLAUSE_PUNCT_REGEX.test(str)) prev.isClauseEnd = true;
|
||||||
} else {
|
} else {
|
||||||
if (tokens.length > 0) {
|
pendingLeading += str;
|
||||||
const prev = tokens[tokens.length - 1];
|
|
||||||
prev.text += str;
|
|
||||||
prev.suffix += str;
|
|
||||||
if (SENTENCE_PUNCT_REGEX.test(str)) prev.isSentenceEnd = true;
|
|
||||||
if (CLAUSE_PUNCT_REGEX.test(str)) prev.isClauseEnd = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no chapters detected at all, supply default single chapter
|
// If text only consisted of pendingLeading symbols and no words
|
||||||
|
if (tokens.length === 0 && pendingLeading.trim().length > 0) {
|
||||||
|
const trimmed = pendingLeading.trim();
|
||||||
|
const { prefix, pivot, suffix, orpIndex } = splitOrp(trimmed, '', '');
|
||||||
|
tokens.push({
|
||||||
|
id: 0,
|
||||||
|
text: trimmed,
|
||||||
|
cleanWord: trimmed,
|
||||||
|
prefix,
|
||||||
|
pivot,
|
||||||
|
suffix,
|
||||||
|
orpIndex,
|
||||||
|
isParagraphEnd: false,
|
||||||
|
isSentenceEnd: false,
|
||||||
|
isClauseEnd: false,
|
||||||
|
isLongWord: false,
|
||||||
|
chapterIndex: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no chapters detected, supply default single chapter
|
||||||
if (chapters.length === 0 && tokens.length > 0) {
|
if (chapters.length === 0 && tokens.length > 0) {
|
||||||
chapters.push({
|
chapters.push({
|
||||||
id: 'chap-1',
|
id: 'chap-1',
|
||||||
|
|||||||
270
src/db/index.ts
270
src/db/index.ts
@@ -24,115 +24,235 @@ export const DEFAULT_SETTINGS: ReaderSettings = {
|
|||||||
const SETTINGS_KEY = 'focus_reader_settings';
|
const SETTINGS_KEY = 'focus_reader_settings';
|
||||||
const ACTIVE_BOOK_KEY = 'focus_active_book_id';
|
const ACTIVE_BOOK_KEY = 'focus_active_book_id';
|
||||||
|
|
||||||
|
// Global In-Memory Fallback Map (for private browsing, restricted sandbox, or quota limits)
|
||||||
|
const memoryBooks = new Map<string, Book>();
|
||||||
|
const memoryMeta = new Map<string, BookMeta>();
|
||||||
|
let useMemoryFallback = false;
|
||||||
|
|
||||||
function openDB(): Promise<IDBDatabase> {
|
function openDB(): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
try {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
request.onupgradeneeded = (event) => {
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
if (!db.objectStoreNames.contains(STORE_BOOKS)) {
|
if (!db.objectStoreNames.contains(STORE_BOOKS)) {
|
||||||
db.createObjectStore(STORE_BOOKS, { keyPath: 'id' });
|
db.createObjectStore(STORE_BOOKS, { keyPath: 'id' });
|
||||||
}
|
}
|
||||||
if (!db.objectStoreNames.contains(STORE_META)) {
|
if (!db.objectStoreNames.contains(STORE_META)) {
|
||||||
const metaStore = db.createObjectStore(STORE_META, { keyPath: 'id' });
|
const metaStore = db.createObjectStore(STORE_META, { keyPath: 'id' });
|
||||||
metaStore.createIndex('lastReadAt', 'lastReadAt', { unique: false });
|
metaStore.createIndex('lastReadAt', 'lastReadAt', { unique: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getDB(): Promise<IDBDatabase | null> {
|
||||||
|
if (useMemoryFallback) return null;
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
useMemoryFallback = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await openDB();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('IndexedDB unavailable, engaging in-memory fallback store:', err);
|
||||||
|
useMemoryFallback = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveBook(book: Book): Promise<void> {
|
export async function saveBook(book: Book): Promise<void> {
|
||||||
const db = await openDB();
|
const { tokens: _tokens, ...meta } = book;
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite');
|
|
||||||
const booksStore = tx.objectStore(STORE_BOOKS);
|
|
||||||
const metaStore = tx.objectStore(STORE_META);
|
|
||||||
|
|
||||||
const { tokens: _tokens, ...meta } = book;
|
// Always sync to memory fallback
|
||||||
|
memoryBooks.set(book.id, book);
|
||||||
|
memoryMeta.set(book.id, meta);
|
||||||
|
|
||||||
booksStore.put(book);
|
const db = await getDB();
|
||||||
metaStore.put(meta);
|
if (!db) return;
|
||||||
|
|
||||||
tx.oncomplete = () => resolve();
|
return new Promise((resolve) => {
|
||||||
tx.onerror = () => reject(tx.error);
|
try {
|
||||||
|
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite');
|
||||||
|
const booksStore = tx.objectStore(STORE_BOOKS);
|
||||||
|
const metaStore = tx.objectStore(STORE_META);
|
||||||
|
|
||||||
|
booksStore.put(book);
|
||||||
|
metaStore.put(meta);
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = (err) => {
|
||||||
|
console.warn('IndexedDB saveBook transaction error, cached in memory:', err);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('saveBook error:', err);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBook(id: string): Promise<Book | undefined> {
|
export async function getBook(id: string): Promise<Book | undefined> {
|
||||||
const db = await openDB();
|
const db = await getDB();
|
||||||
return new Promise((resolve, reject) => {
|
if (!db) {
|
||||||
const tx = db.transaction(STORE_BOOKS, 'readonly');
|
return memoryBooks.get(id);
|
||||||
const store = tx.objectStore(STORE_BOOKS);
|
}
|
||||||
const request = store.get(id);
|
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result as Book | undefined);
|
return new Promise((resolve) => {
|
||||||
request.onerror = () => reject(request.error);
|
try {
|
||||||
|
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readonly');
|
||||||
|
const booksStore = tx.objectStore(STORE_BOOKS);
|
||||||
|
const metaStore = tx.objectStore(STORE_META);
|
||||||
|
|
||||||
|
let bookResult: Book | undefined;
|
||||||
|
let metaResult: BookMeta | undefined;
|
||||||
|
|
||||||
|
const bookReq = booksStore.get(id);
|
||||||
|
bookReq.onsuccess = () => {
|
||||||
|
bookResult = bookReq.result as Book | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const metaReq = metaStore.get(id);
|
||||||
|
metaReq.onsuccess = () => {
|
||||||
|
metaResult = metaReq.result as BookMeta | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
if (bookResult) {
|
||||||
|
// Overlay latest progress & lastReadAt from STORE_META
|
||||||
|
if (metaResult) {
|
||||||
|
bookResult.currentProgress = metaResult.currentProgress;
|
||||||
|
bookResult.lastReadAt = metaResult.lastReadAt;
|
||||||
|
}
|
||||||
|
memoryBooks.set(id, bookResult);
|
||||||
|
if (metaResult) memoryMeta.set(id, metaResult);
|
||||||
|
}
|
||||||
|
resolve(bookResult || memoryBooks.get(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.onerror = () => {
|
||||||
|
resolve(memoryBooks.get(id));
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
resolve(memoryBooks.get(id));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllBooksMeta(): Promise<BookMeta[]> {
|
export async function getAllBooksMeta(): Promise<BookMeta[]> {
|
||||||
const db = await openDB();
|
const db = await getDB();
|
||||||
return new Promise((resolve, reject) => {
|
if (!db) {
|
||||||
const tx = db.transaction(STORE_META, 'readonly');
|
const list = Array.from(memoryMeta.values());
|
||||||
const store = tx.objectStore(STORE_META);
|
list.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
||||||
const request = store.getAll();
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
request.onsuccess = () => {
|
return new Promise((resolve) => {
|
||||||
const list = (request.result as BookMeta[]) || [];
|
try {
|
||||||
// Sort newest read first
|
const tx = db.transaction(STORE_META, 'readonly');
|
||||||
list.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
const store = tx.objectStore(STORE_META);
|
||||||
resolve(list);
|
const request = store.getAll();
|
||||||
};
|
|
||||||
request.onerror = () => reject(request.error);
|
request.onsuccess = () => {
|
||||||
|
const list = (request.result as BookMeta[]) || [];
|
||||||
|
list.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
||||||
|
// Sync to memory
|
||||||
|
list.forEach((m) => memoryMeta.set(m.id, m));
|
||||||
|
resolve(list);
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
const fallback = Array.from(memoryMeta.values());
|
||||||
|
fallback.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
||||||
|
resolve(fallback);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const fallback = Array.from(memoryMeta.values());
|
||||||
|
fallback.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
||||||
|
resolve(fallback);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slim Progress Update:
|
||||||
|
* Updates ONLY STORE_META (a lightweight record < 1KB), avoiding full serialization
|
||||||
|
* and deserialization of megabyte-sized token arrays.
|
||||||
|
*/
|
||||||
export async function updateBookProgress(id: string, progress: number): Promise<void> {
|
export async function updateBookProgress(id: string, progress: number): Promise<void> {
|
||||||
const db = await openDB();
|
const now = Date.now();
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite');
|
|
||||||
const booksStore = tx.objectStore(STORE_BOOKS);
|
|
||||||
const metaStore = tx.objectStore(STORE_META);
|
|
||||||
|
|
||||||
const now = Date.now();
|
// Update in-memory stores immediately
|
||||||
|
const m = memoryMeta.get(id);
|
||||||
|
if (m) {
|
||||||
|
m.currentProgress = progress;
|
||||||
|
m.lastReadAt = now;
|
||||||
|
}
|
||||||
|
const b = memoryBooks.get(id);
|
||||||
|
if (b) {
|
||||||
|
b.currentProgress = progress;
|
||||||
|
b.lastReadAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
const getReq = booksStore.get(id);
|
const db = await getDB();
|
||||||
getReq.onsuccess = () => {
|
if (!db) return;
|
||||||
const book = getReq.result as Book | undefined;
|
|
||||||
if (book) {
|
|
||||||
book.currentProgress = progress;
|
|
||||||
book.lastReadAt = now;
|
|
||||||
booksStore.put(book);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMetaReq = metaStore.get(id);
|
return new Promise((resolve) => {
|
||||||
getMetaReq.onsuccess = () => {
|
try {
|
||||||
const meta = getMetaReq.result as BookMeta | undefined;
|
// ONLY readwrite STORE_META! Zero serialization overhead.
|
||||||
if (meta) {
|
const tx = db.transaction(STORE_META, 'readwrite');
|
||||||
meta.currentProgress = progress;
|
const metaStore = tx.objectStore(STORE_META);
|
||||||
meta.lastReadAt = now;
|
const getMetaReq = metaStore.get(id);
|
||||||
metaStore.put(meta);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tx.oncomplete = () => resolve();
|
getMetaReq.onsuccess = () => {
|
||||||
tx.onerror = () => reject(tx.error);
|
const meta = getMetaReq.result as BookMeta | undefined;
|
||||||
|
if (meta) {
|
||||||
|
meta.currentProgress = progress;
|
||||||
|
meta.lastReadAt = now;
|
||||||
|
metaStore.put(meta);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = (err) => {
|
||||||
|
console.warn('Failed to update meta progress in IndexedDB:', err);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('updateBookProgress error:', err);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBook(id: string): Promise<void> {
|
export async function deleteBook(id: string): Promise<void> {
|
||||||
const db = await openDB();
|
memoryBooks.delete(id);
|
||||||
return new Promise((resolve, reject) => {
|
memoryMeta.delete(id);
|
||||||
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite');
|
|
||||||
tx.objectStore(STORE_BOOKS).delete(id);
|
|
||||||
tx.objectStore(STORE_META).delete(id);
|
|
||||||
|
|
||||||
tx.oncomplete = () => resolve();
|
const db = await getDB();
|
||||||
tx.onerror = () => reject(tx.error);
|
if (!db) return;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite');
|
||||||
|
tx.objectStore(STORE_BOOKS).delete(id);
|
||||||
|
tx.objectStore(STORE_META).delete(id);
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = (err) => {
|
||||||
|
console.warn('Failed to delete book in IndexedDB:', err);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,13 +43,15 @@ export function useReaderEngine({
|
|||||||
const nextToken =
|
const nextToken =
|
||||||
currentIndex < tokens.length - 1 ? tokens[currentIndex + 1] || null : null;
|
currentIndex < tokens.length - 1 ? tokens[currentIndex + 1] || null : null;
|
||||||
|
|
||||||
// Refs for timer and async execution
|
// Refs for timer and clock synchronization
|
||||||
const settingsRef = useRef<ReaderSettings>(settings);
|
const settingsRef = useRef<ReaderSettings>(settings);
|
||||||
const isPlayingRef = useRef<boolean>(isPlaying);
|
const isPlayingRef = useRef<boolean>(isPlaying);
|
||||||
const currentIndexRef = useRef<number>(currentIndex);
|
const currentIndexRef = useRef<number>(currentIndex);
|
||||||
const tokensRef = useRef<Token[]>(tokens);
|
const tokensRef = useRef<Token[]>(tokens);
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const expectedTickTimeRef = useRef<number | null>(null);
|
||||||
const lastSavedIndexRef = useRef<number>(currentIndex);
|
const lastSavedIndexRef = useRef<number>(currentIndex);
|
||||||
|
const lastSaveTimestampRef = useRef<number>(0);
|
||||||
const scheduleNextRef = useRef<((fromIdx: number) => void) | null>(null);
|
const scheduleNextRef = useRef<((fromIdx: number) => void) | null>(null);
|
||||||
|
|
||||||
// Synchronize refs
|
// Synchronize refs
|
||||||
@@ -69,18 +71,24 @@ export function useReaderEngine({
|
|||||||
tokensRef.current = tokens;
|
tokensRef.current = tokens;
|
||||||
}, [tokens]);
|
}, [tokens]);
|
||||||
|
|
||||||
// Periodic throttled persistence to IndexedDB
|
// Periodic throttled persistence to IndexedDB (every 4s or immediately on pause)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!book) return;
|
if (!book) return;
|
||||||
|
|
||||||
if (Math.abs(currentIndex - lastSavedIndexRef.current) >= 5 || !isPlaying) {
|
const now = Date.now();
|
||||||
|
const isPaused = !isPlaying;
|
||||||
|
const timeSinceLastSave = now - lastSaveTimestampRef.current;
|
||||||
|
const shouldSave = isPaused || timeSinceLastSave >= 4000;
|
||||||
|
|
||||||
|
if (shouldSave && currentIndex !== lastSavedIndexRef.current) {
|
||||||
lastSavedIndexRef.current = currentIndex;
|
lastSavedIndexRef.current = currentIndex;
|
||||||
|
lastSaveTimestampRef.current = now;
|
||||||
updateBookProgress(book.id, currentIndex);
|
updateBookProgress(book.id, currentIndex);
|
||||||
onBookProgressUpdate?.(book.id, currentIndex);
|
onBookProgressUpdate?.(book.id, currentIndex);
|
||||||
}
|
}
|
||||||
}, [currentIndex, isPlaying, book, onBookProgressUpdate]);
|
}, [currentIndex, isPlaying, book, onBookProgressUpdate]);
|
||||||
|
|
||||||
// Setup recursion handler in ref
|
// Setup recursion handler with clock drift compensation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scheduleNextRef.current = (fromIndex: number) => {
|
scheduleNextRef.current = (fromIndex: number) => {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
@@ -99,11 +107,26 @@ export function useReaderEngine({
|
|||||||
if (fromIndex >= curTokens.length - 1) {
|
if (fromIndex >= curTokens.length - 1) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
isPlayingRef.current = false;
|
isPlayingRef.current = false;
|
||||||
|
expectedTickTimeRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tok = curTokens[fromIndex];
|
const tok = curTokens[fromIndex];
|
||||||
const delay = calculateTokenDelay(tok, settingsRef.current);
|
const nominalDelay = calculateTokenDelay(tok, settingsRef.current);
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
// Adaptive clock drift compensation
|
||||||
|
if (
|
||||||
|
expectedTickTimeRef.current === null ||
|
||||||
|
Math.abs(now - expectedTickTimeRef.current) > 500
|
||||||
|
) {
|
||||||
|
expectedTickTimeRef.current = now + nominalDelay;
|
||||||
|
} else {
|
||||||
|
expectedTickTimeRef.current += nominalDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drift = now - (expectedTickTimeRef.current - nominalDelay);
|
||||||
|
const compensatedDelay = Math.max(10, Math.round(nominalDelay - drift));
|
||||||
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
timerRef.current = window.setTimeout(() => {
|
||||||
if (!isPlayingRef.current) return;
|
if (!isPlayingRef.current) return;
|
||||||
@@ -113,7 +136,7 @@ export function useReaderEngine({
|
|||||||
currentIndexRef.current = nextIndex;
|
currentIndexRef.current = nextIndex;
|
||||||
|
|
||||||
scheduleNextRef.current?.(nextIndex);
|
scheduleNextRef.current?.(nextIndex);
|
||||||
}, delay);
|
}, compensatedDelay);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,6 +151,7 @@ export function useReaderEngine({
|
|||||||
|
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
isPlayingRef.current = true;
|
isPlayingRef.current = true;
|
||||||
|
expectedTickTimeRef.current = null;
|
||||||
scheduleNextRef.current?.(currentIndexRef.current);
|
scheduleNextRef.current?.(currentIndexRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -136,10 +160,24 @@ export function useReaderEngine({
|
|||||||
window.clearTimeout(timerRef.current);
|
window.clearTimeout(timerRef.current);
|
||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
|
expectedTickTimeRef.current = null;
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
isPlayingRef.current = false;
|
isPlayingRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Background visibility change auto-pause
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden && isPlayingRef.current) {
|
||||||
|
pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [pause]);
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
if (isPlayingRef.current) {
|
if (isPlayingRef.current) {
|
||||||
pause();
|
pause();
|
||||||
@@ -153,6 +191,7 @@ export function useReaderEngine({
|
|||||||
const target = Math.max(0, Math.min(curTokens.length - 1, index));
|
const target = Math.max(0, Math.min(curTokens.length - 1, index));
|
||||||
setCurrentIndex(target);
|
setCurrentIndex(target);
|
||||||
currentIndexRef.current = target;
|
currentIndexRef.current = target;
|
||||||
|
expectedTickTimeRef.current = null;
|
||||||
|
|
||||||
if (isPlayingRef.current) {
|
if (isPlayingRef.current) {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
|
|||||||
Reference in New Issue
Block a user