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.
96 lines
2.7 KiB
TypeScript
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' };
|
|
};
|