openreader/src/lib/client/epub/tts-epub-handoff.ts
Richard R 08d9218ce4 refactor(client,tts,html): migrate EPUB TTS utilities to client and unify HTML progress logic
Move `epub-word-highlight`, `tts-epub-handoff`, and `tts-epub-preload` from shared to client/epub to clarify their usage and improve bundling. Update all imports to use new client paths. Extend TTS and sidebar logic to support HTML document progress tracking and restoration, introducing a new location format for HTML and consistent handling for non-EPUB readers.

BREAKING CHANGE: EPUB TTS utilities relocated to `lib/client/epub`; update import paths. HTML progress persistence now uses a new format incompatible with previous versions.
2026-05-12 17:51:17 -06:00

96 lines
2.7 KiB
TypeScript

import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
export type CompletedEpubBoundarySegment = {
key: string;
fingerprint: string;
completedAt: number;
};
export type EpubReplaySuppressionAction =
| { kind: 'none' }
| { kind: 'skip-to-index'; index: number }
| { kind: 'pause' };
export const EPUB_BOUNDARY_HANDOFF_MAX_AGE_MS = 2 * 60 * 1000;
export const fingerprintEpubBoundarySegment = (text: string): string =>
preprocessSentenceForAudio(text)
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
export const completedEpubBoundarySegment = (
segment: CanonicalTtsSegment | null | undefined,
now = Date.now(),
): CompletedEpubBoundarySegment | null => {
if (!segment?.spansSourceBoundary) return null;
const fingerprint = fingerprintEpubBoundarySegment(segment.text);
if (!fingerprint) return null;
return {
key: segment.key,
fingerprint,
completedAt: now,
};
};
export const resolveEpubBoundaryHandoffStartIndex = (
segments: CanonicalTtsSegment[],
completed: CompletedEpubBoundarySegment | null,
now = Date.now(),
): number => {
if (!completed) return 0;
if (now - completed.completedAt > EPUB_BOUNDARY_HANDOFF_MAX_AGE_MS) return 0;
let index = 0;
while (index < segments.length) {
const segment = segments[index];
if (segment.key === completed.key) {
index += 1;
continue;
}
if (fingerprintEpubBoundarySegment(segment.text) === completed.fingerprint) {
index += 1;
continue;
}
break;
}
return index;
};
export const shouldSuppressCompletedEpubBoundaryReplay = (
segment: CanonicalTtsSegment | null | undefined,
completed: CompletedEpubBoundarySegment | null,
now = Date.now(),
): boolean => {
if (!segment || !completed) return false;
if (now - completed.completedAt > EPUB_BOUNDARY_HANDOFF_MAX_AGE_MS) return false;
if (segment.key === completed.key) return true;
return fingerprintEpubBoundarySegment(segment.text) === completed.fingerprint;
};
export const resolveEpubReplaySuppressionAction = (
segments: CanonicalTtsSegment[],
currentIndex: number,
completed: CompletedEpubBoundarySegment | null,
now = Date.now(),
): EpubReplaySuppressionAction => {
if (!shouldSuppressCompletedEpubBoundaryReplay(segments[currentIndex], completed, now)) {
return { kind: 'none' };
}
let nextIndex = currentIndex + 1;
while (
nextIndex < segments.length
&& shouldSuppressCompletedEpubBoundaryReplay(segments[nextIndex], completed, now)
) {
nextIndex += 1;
}
if (nextIndex < segments.length) {
return { kind: 'skip-to-index', index: nextIndex };
}
return { kind: 'pause' };
};