Transition all TTS-related logic, types, and UI to use the new providerRef/providerType model in place of legacy ttsProvider fields. Introduce a centralized tts-provider-policy module to encapsulate provider/model capability checks, default value resolution, and compatibility logic. Update all API routes, contexts, hooks, components, and tests to use providerRef and providerType, ensuring consistent handling of built-in and shared TTS providers. Remove legacy defaultTtsModel config in favor of per-provider defaults and shared provider admin control. Add the showAllProviderModels runtime flag to restrict users to provider default models when desired. BREAKING CHANGE: ttsProvider fields are replaced by providerRef/providerType throughout the codebase; defaultTtsModel config is removed in favor of per-provider defaults.
1212 lines
38 KiB
TypeScript
1212 lines
38 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
ReactNode,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
RefObject,
|
|
useEffect
|
|
} from 'react';
|
|
|
|
import type { NavItem } from 'epubjs';
|
|
import type { SpineItem } from 'epubjs/types/section';
|
|
import type { Book, Rendition } from 'epubjs';
|
|
|
|
import { createEpubAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/epub';
|
|
import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline';
|
|
import { setLastDocumentLocation } from '@/lib/client/dexie';
|
|
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
|
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
|
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
|
import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker';
|
|
import { canonicalizeEpubSegmentAgainstSpineText } from '@/lib/client/epub/canonicalize-epub-segment';
|
|
import { buildEpubLocator, getSpineItemPlainText } from '@/lib/client/epub/spine-coordinates';
|
|
import { useTTS, type EpubLocatorResolver } from '@/contexts/TTSContext';
|
|
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|
import { createRangeCfi } from '@/lib/client/epub';
|
|
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
|
import {
|
|
buildMonotonicWordToTokenMap,
|
|
buildWordHighlightCacheKey,
|
|
tokenizeCanonicalSegment,
|
|
type EpubCanonicalWordToken,
|
|
} from '@/lib/client/epub/epub-word-highlight';
|
|
import { useParams } from 'next/navigation';
|
|
import { useConfig } from './ConfigContext';
|
|
import type {
|
|
EpubRenderedLocationWalker,
|
|
TTSSentenceAlignment,
|
|
TTSAudiobookFormat,
|
|
TTSAudiobookChapter,
|
|
} from '@/types/tts';
|
|
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
|
import { isStableEpubLocator } from '@/types/client';
|
|
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
|
|
|
interface EPUBContextType {
|
|
currDocData: ArrayBuffer | undefined;
|
|
currDocName: string | undefined;
|
|
currDocPages: number | undefined;
|
|
currDocPage: number | string;
|
|
currDocText: string | undefined;
|
|
setCurrentDocument: (id: string) => Promise<void>;
|
|
clearCurrDoc: () => void;
|
|
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
|
walkUpcomingRenderedLocations: EpubRenderedLocationWalker;
|
|
resolveEpubLocator: EpubLocatorResolver;
|
|
createFullAudioBook: (
|
|
onProgress: (progress: number) => void,
|
|
signal?: AbortSignal,
|
|
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
|
|
bookId?: string,
|
|
format?: TTSAudiobookFormat,
|
|
settings?: AudiobookGenerationSettings
|
|
) => Promise<string>;
|
|
regenerateChapter: (
|
|
chapterIndex: number,
|
|
bookId: string,
|
|
format: TTSAudiobookFormat,
|
|
signal: AbortSignal,
|
|
settings?: AudiobookGenerationSettings
|
|
) => Promise<TTSAudiobookChapter>;
|
|
bookRef: RefObject<Book | null>;
|
|
renditionRef: RefObject<Rendition | undefined>;
|
|
tocRef: RefObject<NavItem[]>;
|
|
locationRef: RefObject<string | number>;
|
|
handleLocationChanged: (location: string | number) => void;
|
|
setRendition: (rendition: Rendition) => void;
|
|
isAudioCombining: boolean;
|
|
highlightSegment: (segment: CanonicalTtsSegment | null | undefined) => void;
|
|
clearHighlights: () => void;
|
|
highlightWordIndex: (
|
|
alignment: TTSSentenceAlignment | undefined,
|
|
wordIndex: number | null | undefined,
|
|
segment: CanonicalTtsSegment | null | undefined
|
|
) => void;
|
|
clearWordHighlights: () => void;
|
|
}
|
|
|
|
const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
|
|
|
|
const EPUB_CONTINUATION_CHARS = 5000;
|
|
|
|
const stepToNextNode = (node: Node | null, root: Node): Node | null => {
|
|
if (!node) return null;
|
|
if (node.firstChild) {
|
|
return node.firstChild;
|
|
}
|
|
|
|
let current: Node | null = node;
|
|
while (current) {
|
|
if (current === root) {
|
|
return null;
|
|
}
|
|
if (current.nextSibling) {
|
|
return current.nextSibling;
|
|
}
|
|
current = current.parentNode;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const stepToPreviousNode = (node: Node | null, root: Node): Node | null => {
|
|
if (!node) return null;
|
|
|
|
if (node.previousSibling) {
|
|
let prev: Node | null = node.previousSibling;
|
|
while (prev?.lastChild) {
|
|
prev = prev.lastChild;
|
|
}
|
|
return prev;
|
|
}
|
|
|
|
let current: Node | null = node.parentNode;
|
|
while (current) {
|
|
if (current === root) {
|
|
return null;
|
|
}
|
|
if (current.previousSibling) {
|
|
let prev: Node | null = current.previousSibling;
|
|
while (prev?.lastChild) {
|
|
prev = prev.lastChild;
|
|
}
|
|
return prev;
|
|
}
|
|
current = current.parentNode;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const getNextTextNode = (node: Node | null, root: Node): Text | null => {
|
|
let next = stepToNextNode(node, root);
|
|
while (next) {
|
|
if (next.nodeType === Node.TEXT_NODE) {
|
|
return next as Text;
|
|
}
|
|
next = stepToNextNode(next, root);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getPreviousTextNode = (node: Node | null, root: Node): Text | null => {
|
|
let prev = stepToPreviousNode(node, root);
|
|
while (prev) {
|
|
if (prev.nodeType === Node.TEXT_NODE) {
|
|
return prev as Text;
|
|
}
|
|
prev = stepToPreviousNode(prev, root);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getLastTextNodeInSubtree = (node: Node | null): Text | null => {
|
|
if (!node) return null;
|
|
if (node.nodeType === Node.TEXT_NODE) return node as Text;
|
|
|
|
let child: Node | null = node.lastChild;
|
|
while (child) {
|
|
const nested = getLastTextNodeInSubtree(child);
|
|
if (nested) return nested;
|
|
child = child.previousSibling;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const collectContinuationFromRange = (range: Range | null | undefined, limit = EPUB_CONTINUATION_CHARS): string => {
|
|
if (typeof window === 'undefined' || !range) {
|
|
return '';
|
|
}
|
|
|
|
const root = range.commonAncestorContainer;
|
|
if (!root) {
|
|
return '';
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
let remaining = limit;
|
|
|
|
const appendFromTextNode = (textNode: Text, offset: number) => {
|
|
if (remaining <= 0) return;
|
|
const textContent = textNode.textContent || '';
|
|
if (offset >= textContent.length) return;
|
|
const slice = textContent.slice(offset, offset + remaining);
|
|
if (slice) {
|
|
parts.push(slice);
|
|
remaining -= slice.length;
|
|
}
|
|
};
|
|
|
|
if (range.endContainer.nodeType === Node.TEXT_NODE) {
|
|
appendFromTextNode(range.endContainer as Text, range.endOffset);
|
|
let nextNode = getNextTextNode(range.endContainer, root);
|
|
while (nextNode && remaining > 0) {
|
|
appendFromTextNode(nextNode, 0);
|
|
nextNode = getNextTextNode(nextNode, root);
|
|
}
|
|
} else {
|
|
let nextNode = getNextTextNode(range.endContainer, root);
|
|
while (nextNode && remaining > 0) {
|
|
appendFromTextNode(nextNode, 0);
|
|
nextNode = getNextTextNode(nextNode, root);
|
|
}
|
|
}
|
|
|
|
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
};
|
|
|
|
const collectLeadingContextFromRange = (range: Range | null | undefined, limit = EPUB_CONTINUATION_CHARS): string => {
|
|
if (typeof window === 'undefined' || !range) {
|
|
return '';
|
|
}
|
|
|
|
const root = range.commonAncestorContainer;
|
|
if (!root) {
|
|
return '';
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
let remaining = limit;
|
|
|
|
const prependFromTextNode = (textNode: Text, endOffset: number) => {
|
|
if (remaining <= 0) return;
|
|
const textContent = textNode.textContent || '';
|
|
const safeEnd = Math.max(0, Math.min(endOffset, textContent.length));
|
|
if (safeEnd <= 0) return;
|
|
const safeStart = Math.max(0, safeEnd - remaining);
|
|
const slice = textContent.slice(safeStart, safeEnd);
|
|
if (slice) {
|
|
parts.unshift(slice);
|
|
remaining -= slice.length;
|
|
}
|
|
};
|
|
|
|
let cursor: Node | null = null;
|
|
|
|
if (range.startContainer.nodeType === Node.TEXT_NODE) {
|
|
const startText = range.startContainer as Text;
|
|
prependFromTextNode(startText, range.startOffset);
|
|
cursor = startText;
|
|
} else {
|
|
const startNode = range.startContainer;
|
|
let anchor: Node | null = null;
|
|
if (range.startOffset > 0) {
|
|
anchor = startNode.childNodes[range.startOffset - 1] ?? null;
|
|
}
|
|
if (!anchor) {
|
|
anchor = stepToPreviousNode(startNode, root);
|
|
}
|
|
|
|
const anchorText = getLastTextNodeInSubtree(anchor);
|
|
if (anchorText) {
|
|
prependFromTextNode(anchorText, (anchorText.textContent || '').length);
|
|
cursor = anchorText;
|
|
} else {
|
|
cursor = anchor;
|
|
}
|
|
}
|
|
|
|
let prevNode = getPreviousTextNode(cursor, root);
|
|
while (prevNode && remaining > 0) {
|
|
prependFromTextNode(prevNode, (prevNode.textContent || '').length);
|
|
prevNode = getPreviousTextNode(prevNode, root);
|
|
}
|
|
|
|
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
};
|
|
|
|
type EpubMappedPosition = {
|
|
node: Text;
|
|
offset: number;
|
|
};
|
|
|
|
type EpubMappedChar = {
|
|
char: string;
|
|
position: EpubMappedPosition;
|
|
};
|
|
|
|
type EpubRenderedTextMap = {
|
|
sourceKey: string;
|
|
chars: EpubMappedPosition[];
|
|
content: {
|
|
cfiFromRange: (range: Range) => string;
|
|
};
|
|
};
|
|
|
|
type EpubWordHighlightMapCache = {
|
|
key: string;
|
|
wordToToken: number[];
|
|
tokens: EpubCanonicalWordToken[];
|
|
};
|
|
|
|
const cloneMappedChar = (char: string, source: EpubMappedChar): EpubMappedChar => ({
|
|
char,
|
|
position: source.position,
|
|
});
|
|
|
|
const replaceMappedUrls = (tokens: EpubMappedChar[]): EpubMappedChar[] => {
|
|
const text = tokens.map((token) => token.char).join('');
|
|
const urlPattern = /\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi;
|
|
const replaced: EpubMappedChar[] = [];
|
|
let cursor = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = urlPattern.exec(text)) !== null) {
|
|
const start = match.index;
|
|
const end = start + match[0].length;
|
|
replaced.push(...tokens.slice(cursor, start));
|
|
|
|
const anchor = tokens[start] ?? tokens[Math.max(0, end - 1)];
|
|
if (anchor) {
|
|
const replacement = `- (link to ${match[1]}) -`;
|
|
for (const char of replacement) {
|
|
replaced.push(cloneMappedChar(char, anchor));
|
|
}
|
|
}
|
|
cursor = end;
|
|
}
|
|
|
|
replaced.push(...tokens.slice(cursor));
|
|
return replaced;
|
|
};
|
|
|
|
const removeMappedHyphenation = (tokens: EpubMappedChar[]): EpubMappedChar[] => {
|
|
const text = tokens.map((token) => token.char).join('');
|
|
const hyphenPattern = /(\w+)-\s+(\w+)/g;
|
|
const replaced: EpubMappedChar[] = [];
|
|
let cursor = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = hyphenPattern.exec(text)) !== null) {
|
|
const start = match.index;
|
|
const full = match[0];
|
|
const first = match[1];
|
|
const second = match[2];
|
|
const secondOffset = full.lastIndexOf(second);
|
|
|
|
replaced.push(...tokens.slice(cursor, start));
|
|
replaced.push(...tokens.slice(start, start + first.length));
|
|
replaced.push(...tokens.slice(start + secondOffset, start + secondOffset + second.length));
|
|
cursor = start + full.length;
|
|
}
|
|
|
|
replaced.push(...tokens.slice(cursor));
|
|
return replaced;
|
|
};
|
|
|
|
const normalizeMappedTokensForTts = (tokens: EpubMappedChar[]): EpubMappedChar[] => {
|
|
const withoutLinks = replaceMappedUrls(tokens);
|
|
const withoutHyphenation = removeMappedHyphenation(withoutLinks);
|
|
const normalized: EpubMappedChar[] = [];
|
|
let pendingWhitespace: EpubMappedChar | null = null;
|
|
|
|
const flushWhitespace = () => {
|
|
if (!pendingWhitespace || normalized.length === 0 || normalized[normalized.length - 1].char === ' ') {
|
|
pendingWhitespace = null;
|
|
return;
|
|
}
|
|
normalized.push(cloneMappedChar(' ', pendingWhitespace));
|
|
pendingWhitespace = null;
|
|
};
|
|
|
|
for (const token of withoutHyphenation) {
|
|
if (token.char === '*') continue;
|
|
if (/\s/.test(token.char)) {
|
|
pendingWhitespace ??= token;
|
|
continue;
|
|
}
|
|
|
|
flushWhitespace();
|
|
normalized.push(token);
|
|
}
|
|
|
|
if (normalized[normalized.length - 1]?.char === ' ') {
|
|
normalized.pop();
|
|
}
|
|
|
|
return normalized;
|
|
};
|
|
|
|
const collectMappedTextFromRange = (range: Range): EpubMappedChar[] => {
|
|
const root = range.commonAncestorContainer;
|
|
const doc = range.startContainer.ownerDocument ?? (range.startContainer as Document);
|
|
const mapped: EpubMappedChar[] = [];
|
|
|
|
const addTextSlice = (textNode: Text, start: number, end: number) => {
|
|
const text = textNode.textContent || '';
|
|
const safeStart = Math.max(0, Math.min(start, text.length));
|
|
const safeEnd = Math.max(safeStart, Math.min(end, text.length));
|
|
for (let offset = safeStart; offset < safeEnd; offset += 1) {
|
|
mapped.push({
|
|
char: text[offset],
|
|
position: { node: textNode, offset },
|
|
});
|
|
}
|
|
};
|
|
|
|
if (root.nodeType === Node.TEXT_NODE) {
|
|
addTextSlice(root as Text, range.startOffset, range.endOffset);
|
|
return mapped;
|
|
}
|
|
|
|
const nodeFilter = doc.defaultView?.NodeFilter ?? NodeFilter;
|
|
const walker = doc.createTreeWalker(
|
|
root,
|
|
nodeFilter.SHOW_TEXT,
|
|
{
|
|
acceptNode: (node) => {
|
|
try {
|
|
return range.intersectsNode(node)
|
|
? nodeFilter.FILTER_ACCEPT
|
|
: nodeFilter.FILTER_REJECT;
|
|
} catch {
|
|
return nodeFilter.FILTER_REJECT;
|
|
}
|
|
},
|
|
},
|
|
);
|
|
|
|
let textNode = walker.nextNode() as Text | null;
|
|
while (textNode) {
|
|
const text = textNode.textContent || '';
|
|
let start = 0;
|
|
let end = text.length;
|
|
|
|
if (textNode === range.startContainer) {
|
|
start = range.startOffset;
|
|
}
|
|
if (textNode === range.endContainer) {
|
|
end = range.endOffset;
|
|
}
|
|
|
|
addTextSlice(textNode, start, end);
|
|
textNode = walker.nextNode() as Text | null;
|
|
}
|
|
|
|
return mapped;
|
|
};
|
|
|
|
const buildRenderedTextMaps = (
|
|
rendition: Rendition,
|
|
rangeCfi: string,
|
|
sourceKey: string,
|
|
): EpubRenderedTextMap[] => {
|
|
const contents = rendition.getContents();
|
|
const contentsArray = Array.isArray(contents) ? contents : [contents];
|
|
const maps: EpubRenderedTextMap[] = [];
|
|
|
|
for (const content of contentsArray) {
|
|
try {
|
|
const range = content.range(rangeCfi);
|
|
if (!range) continue;
|
|
|
|
const normalized = normalizeMappedTokensForTts(collectMappedTextFromRange(range));
|
|
if (!normalized.length) continue;
|
|
|
|
maps.push({
|
|
sourceKey,
|
|
chars: normalized.map((token) => token.position),
|
|
content,
|
|
});
|
|
} catch {
|
|
// Not every displayed iframe can resolve every CFI in spread mode.
|
|
}
|
|
}
|
|
|
|
return maps;
|
|
};
|
|
|
|
const createRangeFromMappedOffsets = (
|
|
map: EpubRenderedTextMap,
|
|
startOffset: number,
|
|
endOffset: number,
|
|
): Range | null => {
|
|
const start = Math.max(0, Math.min(startOffset, map.chars.length));
|
|
const end = Math.max(start, Math.min(endOffset, map.chars.length));
|
|
if (end <= start) return null;
|
|
|
|
const startPosition = map.chars[start];
|
|
const endPosition = map.chars[end - 1];
|
|
if (!startPosition || !endPosition) return null;
|
|
|
|
const doc = startPosition.node.ownerDocument;
|
|
const range = doc.createRange();
|
|
range.setStart(startPosition.node, startPosition.offset);
|
|
range.setEnd(endPosition.node, endPosition.offset + 1);
|
|
return range;
|
|
};
|
|
|
|
const resolveVisibleSegmentRange = (
|
|
maps: EpubRenderedTextMap[],
|
|
segment: CanonicalTtsSegment | null | undefined,
|
|
): { map: EpubRenderedTextMap; range: Range; startOffset: number; endOffset: number } | null => {
|
|
if (!segment) return null;
|
|
|
|
for (const map of maps) {
|
|
const startsInMap = segment.startAnchor.sourceKey === map.sourceKey;
|
|
const endsInMap = segment.endAnchor.sourceKey === map.sourceKey;
|
|
if (!startsInMap && !endsInMap) continue;
|
|
|
|
const startOffset = startsInMap ? segment.startAnchor.offset : 0;
|
|
const endOffset = endsInMap ? segment.endAnchor.offset : map.chars.length;
|
|
const range = createRangeFromMappedOffsets(map, startOffset, endOffset);
|
|
if (range) {
|
|
return { map, range, startOffset, endOffset };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
|
|
/**
|
|
* Provider component for EPUB functionality
|
|
* Manages the state and operations for EPUB document handling
|
|
* @param {Object} props - Component props
|
|
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
|
*/
|
|
export function EPUBProvider({ children }: { children: ReactNode }) {
|
|
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS();
|
|
const { authEnabled } = useAuthConfig();
|
|
const { id } = useParams();
|
|
// Configuration context to get TTS settings
|
|
const {
|
|
apiKey,
|
|
baseUrl,
|
|
providerRef,
|
|
ttsSegmentMaxBlockLength,
|
|
smartSentenceSplitting,
|
|
epubTheme,
|
|
epubHighlightEnabled,
|
|
} = useConfig();
|
|
// Current document state
|
|
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
|
const [currDocName, setCurrDocName] = useState<string>();
|
|
const [currDocText, setCurrDocText] = useState<string>();
|
|
// Mirror state into a ref so resolveEpubLocator (registered once with
|
|
// TTSContext via a stable callback) can always read the latest page text
|
|
// without forcing re-registration on every page turn.
|
|
const currDocTextRef = useRef<string | undefined>(undefined);
|
|
useEffect(() => { currDocTextRef.current = currDocText; }, [currDocText]);
|
|
const [isAudioCombining] = useState(false);
|
|
|
|
// Add new refs
|
|
const bookRef = useRef<Book | null>(null);
|
|
const renditionRef = useRef<Rendition | undefined>(undefined);
|
|
const tocRef = useRef<NavItem[]>([]);
|
|
const locationRef = useRef<string | number>(currDocPage);
|
|
const isEPUBSetOnce = useRef(false);
|
|
// Should pause ref
|
|
const shouldPauseRef = useRef(true);
|
|
// Track current highlight CFI for removal
|
|
const currentHighlightCfi = useRef<string | null>(null);
|
|
const currentWordHighlightCfi = useRef<string | null>(null);
|
|
const renderedTextMapsRef = useRef<EpubRenderedTextMap[]>([]);
|
|
const wordHighlightMapCacheRef = useRef<EpubWordHighlightMapCache | null>(null);
|
|
const renderedLocationCloneManagerRef = useRef<EpubRenderedLocationCloneManager>(
|
|
new EpubRenderedLocationCloneManager(),
|
|
);
|
|
|
|
useEffect(() => () => {
|
|
void renderedLocationCloneManagerRef.current.destroy();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
renderedLocationCloneManagerRef.current.invalidate();
|
|
}, [currDocData]);
|
|
|
|
/**
|
|
* Clears all current document state and stops any active TTS
|
|
*/
|
|
const clearCurrDoc = useCallback(() => {
|
|
setCurrDocData(undefined);
|
|
setCurrDocName(undefined);
|
|
setCurrDocText(undefined);
|
|
setCurrDocPages(undefined);
|
|
isEPUBSetOnce.current = false;
|
|
bookRef.current = null;
|
|
renditionRef.current = undefined;
|
|
locationRef.current = 1;
|
|
tocRef.current = [];
|
|
renderedTextMapsRef.current = [];
|
|
wordHighlightMapCacheRef.current = null;
|
|
renderedLocationCloneManagerRef.current.invalidate();
|
|
stop();
|
|
}, [setCurrDocPages, stop]);
|
|
|
|
/**
|
|
* Sets the current document based on its ID by fetching from IndexedDB
|
|
* @param {string} id - The unique identifier of the document
|
|
* @throws {Error} When document data is empty or retrieval fails
|
|
*/
|
|
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
|
try {
|
|
const meta = await getDocumentMetadata(id);
|
|
if (!meta) {
|
|
clearCurrDoc();
|
|
console.error('Document not found on server');
|
|
return;
|
|
}
|
|
|
|
const doc = await ensureCachedDocument(meta);
|
|
if (doc.type !== 'epub') {
|
|
clearCurrDoc();
|
|
console.error('Document is not an EPUB');
|
|
return;
|
|
}
|
|
|
|
if (doc.data.byteLength === 0) {
|
|
console.error('Retrieved ArrayBuffer is empty');
|
|
throw new Error('Empty document data');
|
|
}
|
|
|
|
setCurrDocName(doc.name);
|
|
setCurrDocData(doc.data); // Store ArrayBuffer directly
|
|
} catch (error) {
|
|
console.error('Failed to get EPUB document:', error);
|
|
clearCurrDoc(); // Clean up on error
|
|
}
|
|
}, [clearCurrDoc]);
|
|
|
|
/**
|
|
* Extracts text content from the current EPUB page/location
|
|
* @param {Book} book - The EPUB.js Book instance
|
|
* @param {Rendition} rendition - The EPUB.js Rendition instance
|
|
* @param {boolean} shouldPause - Whether to pause TTS
|
|
* @returns {Promise<string>} The extracted text content
|
|
*/
|
|
const extractPageText = useCallback(async (book: Book, rendition: Rendition, shouldPause = false): Promise<string> => {
|
|
try {
|
|
const location = rendition?.location;
|
|
if (!location) return '';
|
|
const { start, end } = location;
|
|
if (!start?.cfi || !end?.cfi || !book || !book.isOpen || !rendition) return '';
|
|
|
|
const rangeCfi = createRangeCfi(start.cfi, end.cfi);
|
|
|
|
const range = await book.getRange(rangeCfi);
|
|
if (!range) {
|
|
console.warn('Failed to get range from CFI:', rangeCfi);
|
|
return '';
|
|
}
|
|
const textContent = range.toString().trim();
|
|
renderedTextMapsRef.current = buildRenderedTextMaps(
|
|
rendition,
|
|
rangeCfi,
|
|
normalizeTtsLocationKey(start.cfi),
|
|
);
|
|
wordHighlightMapCacheRef.current = null;
|
|
const leadingPreview = collectLeadingContextFromRange(range);
|
|
const continuationPreview = collectContinuationFromRange(range);
|
|
|
|
if (smartSentenceSplitting) {
|
|
setTTSText(textContent, {
|
|
shouldPause,
|
|
location: start.cfi,
|
|
previousText: leadingPreview,
|
|
nextLocation: end.cfi,
|
|
nextText: continuationPreview
|
|
});
|
|
} else {
|
|
// When smart splitting is disabled, behave like the original implementation:
|
|
// send only the current page/location text without any continuation preview.
|
|
setTTSText(textContent, {
|
|
shouldPause,
|
|
location: start.cfi,
|
|
});
|
|
}
|
|
setCurrDocText(textContent);
|
|
|
|
return textContent;
|
|
} catch (error) {
|
|
console.error('Error extracting EPUB text:', error);
|
|
return '';
|
|
}
|
|
}, [setTTSText, smartSentenceSplitting]);
|
|
|
|
/**
|
|
* Extracts text content from the entire EPUB book
|
|
* @returns {Promise<string[]>} Array of text content from each section
|
|
*/
|
|
const loadSpineSection = useCallback(async (href: string) => {
|
|
const book = bookRef.current;
|
|
if (!book?.isOpen) return null;
|
|
const section = book.spine.get(href);
|
|
if (!section) return null;
|
|
const loaded = await Promise.resolve(section.load(book.load.bind(book)));
|
|
const doc = (() => {
|
|
if (!loaded) return null;
|
|
if (typeof Document !== 'undefined' && loaded instanceof Document) {
|
|
return loaded;
|
|
}
|
|
const element = loaded as unknown as Element;
|
|
if (element?.ownerDocument) {
|
|
return element.ownerDocument;
|
|
}
|
|
const sectionWithDocument = section as unknown as { document?: Document };
|
|
return sectionWithDocument.document ?? null;
|
|
})();
|
|
if (!doc) return null;
|
|
return { section, doc };
|
|
}, []);
|
|
|
|
const extractBookText = useCallback(async (): Promise<Array<{ text: string; href: string }>> => {
|
|
try {
|
|
if (!bookRef.current || !bookRef.current.isOpen) return [{ text: '', href: '' }];
|
|
|
|
const book = bookRef.current;
|
|
const spine = book.spine;
|
|
const promises: Promise<{ text: string; href: string }>[] = [];
|
|
|
|
spine.each((item: SpineItem) => {
|
|
const url = item.href || '';
|
|
if (!url) return;
|
|
const promise = loadSpineSection(url)
|
|
.then((loaded) => {
|
|
if (!loaded?.doc) return { text: '', href: url };
|
|
const text = loaded.doc.body?.textContent || '';
|
|
return { text, href: url };
|
|
})
|
|
.catch((err) => {
|
|
console.error(`Error loading section ${url}:`, err);
|
|
return { text: '', href: url };
|
|
})
|
|
.finally(() => {
|
|
const section = book.spine.get(url);
|
|
section?.unload?.();
|
|
});
|
|
|
|
promises.push(promise);
|
|
});
|
|
|
|
const textArray = await Promise.all(promises);
|
|
const filteredArray = textArray.filter(item => item.text.trim() !== '');
|
|
return filteredArray;
|
|
} catch (error) {
|
|
console.error('Error extracting EPUB text:', error);
|
|
return [{ text: '', href: '' }];
|
|
}
|
|
}, [loadSpineSection]);
|
|
|
|
/**
|
|
* Resolves a draft EPUB locator (typically `{ readerType: 'epub', location:
|
|
* <CFI> }`) into stable spine coordinates using the live `Book` instance.
|
|
* Registered with TTSContext so segment-persist payloads are normalised to
|
|
* viewport-independent coordinates before they hit the server.
|
|
*
|
|
* Returns null when there's no live book or the CFI doesn't resolve.
|
|
*/
|
|
const resolveEpubLocator = useCallback<EpubLocatorResolver>(async (
|
|
draft,
|
|
segmentText,
|
|
options,
|
|
) => {
|
|
const book = bookRef.current;
|
|
if (!book || !book.isOpen) return null;
|
|
const resolvedLocator = (() => {
|
|
if (isStableEpubLocator(draft)) return Promise.resolve(draft);
|
|
const cfi = (typeof draft.cfi === 'string' && draft.cfi)
|
|
|| (typeof draft.location === 'string' && draft.location)
|
|
|| '';
|
|
if (!cfi) return Promise.resolve<TTSSegmentLocator | null>(null);
|
|
// Pass the current rendered page's text as the chunk anchor so the
|
|
// per-segment search starts at this page's position in the spine.
|
|
const chunkText = currDocTextRef.current;
|
|
return buildEpubLocator(book, cfi, segmentText, chunkText);
|
|
})();
|
|
|
|
const stable = await resolvedLocator;
|
|
if (!stable || !isStableEpubLocator(stable)) return null;
|
|
|
|
const spineText = await getSpineItemPlainText(book, stable.spineHref);
|
|
const canonical = canonicalizeEpubSegmentAgainstSpineText({
|
|
segmentText,
|
|
spineText,
|
|
spineHref: stable.spineHref,
|
|
spineIndex: stable.spineIndex,
|
|
hintCharOffset: stable.charOffset,
|
|
cfi: stable.cfi,
|
|
keyPrefix: options?.keyPrefix,
|
|
maxBlockLength: options?.maxBlockLength ?? ttsSegmentMaxBlockLength,
|
|
});
|
|
if (!canonical) return null;
|
|
|
|
return {
|
|
locator: canonical.locator,
|
|
segmentKey: canonical.segmentKey,
|
|
segmentIndex: canonical.segmentIndex,
|
|
text: canonical.text,
|
|
};
|
|
}, [ttsSegmentMaxBlockLength]);
|
|
|
|
const walkUpcomingRenderedLocations = useCallback<EpubRenderedLocationWalker>(async (startCfi, depth, signal) => {
|
|
if (!startCfi || depth <= 0 || signal.aborted) return [];
|
|
const visibleRendition = renditionRef.current;
|
|
if (!currDocData || !visibleRendition || typeof document === 'undefined') return [];
|
|
|
|
const visibleSettings = (visibleRendition as unknown as {
|
|
settings?: {
|
|
spread?: string;
|
|
};
|
|
manager?: { stage?: { container?: Element | null }; container?: Element | null };
|
|
}).settings;
|
|
const containerElement =
|
|
(visibleRendition as unknown as { manager?: { stage?: { container?: Element | null }; container?: Element | null } }).manager?.stage?.container
|
|
?? (visibleRendition as unknown as { manager?: { container?: Element | null } }).manager?.container
|
|
?? null;
|
|
const bounds = containerElement?.getBoundingClientRect?.();
|
|
const width = Math.max(320, Math.floor(bounds?.width || 900));
|
|
const height = Math.max(320, Math.floor(bounds?.height || 700));
|
|
|
|
const visibleContents = typeof visibleRendition.getContents === 'function'
|
|
? visibleRendition.getContents()
|
|
: [];
|
|
const visibleContent = Array.isArray(visibleContents) ? visibleContents[0] : visibleContents;
|
|
const contentDoc = (visibleContent as { document?: Document | null } | null | undefined)?.document ?? null;
|
|
const contentBody = contentDoc?.body ?? null;
|
|
const bodyStyle = contentBody ? getComputedStyle(contentBody) : null;
|
|
|
|
const theme = epubTheme
|
|
? {
|
|
foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'),
|
|
base: getComputedStyle(document.documentElement).getPropertyValue('--base'),
|
|
fontFamily: bodyStyle?.fontFamily || undefined,
|
|
fontSize: bodyStyle?.fontSize || undefined,
|
|
lineHeight: bodyStyle?.lineHeight || undefined,
|
|
fontWeight: bodyStyle?.fontWeight || undefined,
|
|
letterSpacing: bodyStyle?.letterSpacing || undefined,
|
|
wordSpacing: bodyStyle?.wordSpacing || undefined,
|
|
}
|
|
: null;
|
|
|
|
return renderedLocationCloneManagerRef.current.walk({
|
|
data: currDocData,
|
|
startCfi,
|
|
depth,
|
|
signal,
|
|
width,
|
|
height,
|
|
spread: visibleSettings?.spread,
|
|
theme,
|
|
});
|
|
}, [currDocData, epubTheme]);
|
|
|
|
const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({
|
|
extractBookText,
|
|
getTocItems: () => tocRef.current || [],
|
|
}), [extractBookText]);
|
|
|
|
/**
|
|
* Creates a complete audiobook by processing all text through NLP and TTS
|
|
*/
|
|
const createFullAudioBook = useCallback(async (
|
|
onProgress: (progress: number) => void,
|
|
signal?: AbortSignal,
|
|
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
|
|
providedBookId?: string,
|
|
format: TTSAudiobookFormat = 'mp3',
|
|
settings?: AudiobookGenerationSettings
|
|
): Promise<string> => {
|
|
try {
|
|
return await runAudiobookGeneration({
|
|
adapter: audiobookAdapter,
|
|
apiKey,
|
|
baseUrl,
|
|
defaultProvider: providerRef,
|
|
onProgress,
|
|
signal,
|
|
onChapterComplete,
|
|
providedBookId,
|
|
format,
|
|
settings,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating audiobook:', error);
|
|
throw error;
|
|
}
|
|
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
|
|
|
/**
|
|
* Regenerates a specific chapter of the audiobook
|
|
*/
|
|
const regenerateChapter = useCallback(async (
|
|
chapterIndex: number,
|
|
bookId: string,
|
|
format: TTSAudiobookFormat,
|
|
signal: AbortSignal,
|
|
settings?: AudiobookGenerationSettings
|
|
): Promise<TTSAudiobookChapter> => {
|
|
try {
|
|
return await regenerateAudiobookChapter({
|
|
adapter: audiobookAdapter,
|
|
chapterIndex,
|
|
bookId,
|
|
format,
|
|
signal,
|
|
apiKey,
|
|
baseUrl,
|
|
defaultProvider: providerRef,
|
|
settings,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
|
throw new Error('Chapter regeneration cancelled');
|
|
}
|
|
console.error('Error regenerating chapter:', error);
|
|
throw error;
|
|
}
|
|
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
|
|
|
const setRendition = useCallback((rendition: Rendition) => {
|
|
bookRef.current = rendition.book;
|
|
renditionRef.current = rendition;
|
|
}, []);
|
|
|
|
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
|
|
const book = bookRef.current;
|
|
const rendition = renditionRef.current;
|
|
if (!book?.isOpen || !rendition) return false;
|
|
|
|
const guardNavigationPromise = (promiseLike: unknown): void => {
|
|
const promise = Promise.resolve(promiseLike);
|
|
void promise.catch((error) => {
|
|
console.warn(`EPUB rendition ${navigation} failed:`, error);
|
|
});
|
|
};
|
|
|
|
try {
|
|
if (navigation === 'display') {
|
|
if (!location) return false;
|
|
guardNavigationPromise(rendition.display(location));
|
|
return true;
|
|
}
|
|
if (navigation === 'next') {
|
|
guardNavigationPromise(rendition.next());
|
|
return true;
|
|
}
|
|
guardNavigationPromise(rendition.prev());
|
|
return true;
|
|
} catch (error) {
|
|
console.warn(`EPUB rendition ${navigation} failed:`, error);
|
|
return false;
|
|
}
|
|
}, []);
|
|
|
|
const handleLocationChanged = useCallback((location: string | number) => {
|
|
// Handle directional navigation before first-location initialization so
|
|
// "prev"/"next" are not treated as raw CFI strings.
|
|
if ((location === 'next' || location === 'prev') && renditionRef.current) {
|
|
if (!isEPUBSetOnce.current) {
|
|
setIsEPUB(true);
|
|
isEPUBSetOnce.current = true;
|
|
}
|
|
shouldPauseRef.current = false;
|
|
safeRenditionNavigate(location === 'next' ? 'next' : 'prev');
|
|
return;
|
|
}
|
|
|
|
// Set the EPUB flag once the location changes
|
|
if (!isEPUBSetOnce.current) {
|
|
setIsEPUB(true);
|
|
isEPUBSetOnce.current = true;
|
|
|
|
safeRenditionNavigate('display', location.toString());
|
|
return;
|
|
}
|
|
|
|
if (!bookRef.current?.isOpen || !renditionRef.current) return;
|
|
|
|
// If the location is a CFI string that doesn't match the current rendered position,
|
|
// navigate there and let the subsequent locationChanged callback handle text extraction.
|
|
if (typeof location === 'string' && location !== 'next' && location !== 'prev' && renditionRef.current?.location) {
|
|
const currentStartCfi = renditionRef.current.location?.start?.cfi;
|
|
if (currentStartCfi && location !== currentStartCfi) {
|
|
// Programmatic cross-location jumps (segments sidebar / TTS navigation)
|
|
// should keep autoplay intent after the rendition finishes navigating.
|
|
shouldPauseRef.current = false;
|
|
safeRenditionNavigate('display', location);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Handle special 'next' and 'prev' cases
|
|
if (location === 'next' && renditionRef.current) {
|
|
shouldPauseRef.current = false;
|
|
safeRenditionNavigate('next');
|
|
return;
|
|
}
|
|
if (location === 'prev' && renditionRef.current) {
|
|
shouldPauseRef.current = false;
|
|
safeRenditionNavigate('prev');
|
|
return;
|
|
}
|
|
|
|
// Save the location to IndexedDB if not initial
|
|
if (id && locationRef.current !== 1) {
|
|
setLastDocumentLocation(id as string, location.toString());
|
|
if (authEnabled) {
|
|
scheduleDocumentProgressSync({
|
|
documentId: id as string,
|
|
readerType: 'epub',
|
|
location: location.toString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
skipToLocation(location);
|
|
|
|
locationRef.current = location;
|
|
if (bookRef.current && renditionRef.current) {
|
|
extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current);
|
|
shouldPauseRef.current = true;
|
|
}
|
|
}, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled, safeRenditionNavigate]);
|
|
|
|
const clearWordHighlights = useCallback(() => {
|
|
if (!renditionRef.current) return;
|
|
if (currentWordHighlightCfi.current) {
|
|
renditionRef.current.annotations.remove(currentWordHighlightCfi.current, 'highlight');
|
|
currentWordHighlightCfi.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const clearHighlights = useCallback(() => {
|
|
if (renditionRef.current && currentHighlightCfi.current) {
|
|
renditionRef.current.annotations.remove(currentHighlightCfi.current, 'highlight');
|
|
currentHighlightCfi.current = null;
|
|
}
|
|
clearWordHighlights();
|
|
}, [clearWordHighlights]);
|
|
|
|
const highlightSegment = useCallback((segment: CanonicalTtsSegment | null | undefined) => {
|
|
if (!renditionRef.current) return;
|
|
|
|
clearHighlights();
|
|
|
|
if (!epubHighlightEnabled || !segment) return;
|
|
|
|
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
|
|
if (!resolved) return;
|
|
|
|
try {
|
|
const cfi = resolved.map.content.cfiFromRange(resolved.range);
|
|
currentHighlightCfi.current = cfi;
|
|
renditionRef.current.annotations.add(
|
|
'highlight',
|
|
cfi,
|
|
{},
|
|
() => { },
|
|
'',
|
|
{ fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' },
|
|
);
|
|
} catch (error) {
|
|
console.error('Error highlighting EPUB segment:', error);
|
|
}
|
|
}, [clearHighlights, epubHighlightEnabled]);
|
|
|
|
// Effect to clear highlights when disabled
|
|
useEffect(() => {
|
|
if (!epubHighlightEnabled) {
|
|
clearHighlights();
|
|
}
|
|
}, [epubHighlightEnabled, clearHighlights]);
|
|
|
|
const highlightWordIndex = useCallback((
|
|
alignment: TTSSentenceAlignment | undefined,
|
|
wordIndex: number | null | undefined,
|
|
segment: CanonicalTtsSegment | null | undefined
|
|
) => {
|
|
clearWordHighlights();
|
|
|
|
if (!epubHighlightEnabled) return;
|
|
if (!alignment) return;
|
|
if (wordIndex === null || wordIndex === undefined || wordIndex < 0) return;
|
|
|
|
const words = alignment.words || [];
|
|
if (!words.length || wordIndex >= words.length) return;
|
|
|
|
if (!renditionRef.current) return;
|
|
|
|
if (!segment || segment.startAnchor.sourceKey !== segment.ownerSourceKey) return;
|
|
|
|
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
|
|
if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return;
|
|
|
|
const cacheKey = buildWordHighlightCacheKey(segment, alignment);
|
|
if (wordHighlightMapCacheRef.current?.key !== cacheKey) {
|
|
const tokens = tokenizeCanonicalSegment(segment);
|
|
wordHighlightMapCacheRef.current = {
|
|
key: cacheKey,
|
|
tokens,
|
|
wordToToken: buildMonotonicWordToTokenMap(words, tokens),
|
|
};
|
|
}
|
|
|
|
const cached = wordHighlightMapCacheRef.current;
|
|
const tokenIndex = cached.wordToToken[wordIndex] ?? -1;
|
|
if (tokenIndex < 0) return;
|
|
|
|
const token = cached.tokens[tokenIndex];
|
|
if (!token) return;
|
|
if (token.sourceStart < resolved.startOffset || token.sourceEnd > resolved.endOffset) return;
|
|
|
|
const wordRange = createRangeFromMappedOffsets(resolved.map, token.sourceStart, token.sourceEnd);
|
|
if (!wordRange) return;
|
|
|
|
try {
|
|
const wordCfi = resolved.map.content.cfiFromRange(wordRange);
|
|
currentWordHighlightCfi.current = wordCfi;
|
|
renditionRef.current.annotations.add(
|
|
'highlight',
|
|
wordCfi,
|
|
{},
|
|
() => { },
|
|
'',
|
|
{
|
|
fill: 'var(--accent)',
|
|
'fill-opacity': '0.4',
|
|
'mix-blend-mode': 'multiply',
|
|
}
|
|
);
|
|
} catch (error) {
|
|
console.error('Error highlighting EPUB word:', error);
|
|
}
|
|
}, [epubHighlightEnabled, clearWordHighlights]);
|
|
|
|
|
|
|
|
// Context value memoization
|
|
const contextValue = useMemo(
|
|
() => ({
|
|
setCurrentDocument,
|
|
currDocData,
|
|
currDocName,
|
|
currDocPages,
|
|
currDocPage,
|
|
currDocText,
|
|
clearCurrDoc,
|
|
extractPageText,
|
|
walkUpcomingRenderedLocations,
|
|
resolveEpubLocator,
|
|
createFullAudioBook,
|
|
regenerateChapter,
|
|
bookRef,
|
|
renditionRef,
|
|
tocRef,
|
|
locationRef,
|
|
handleLocationChanged,
|
|
setRendition,
|
|
isAudioCombining,
|
|
highlightSegment,
|
|
clearHighlights,
|
|
highlightWordIndex,
|
|
clearWordHighlights,
|
|
}),
|
|
[
|
|
setCurrentDocument,
|
|
currDocData,
|
|
currDocName,
|
|
currDocPages,
|
|
currDocPage,
|
|
currDocText,
|
|
clearCurrDoc,
|
|
extractPageText,
|
|
walkUpcomingRenderedLocations,
|
|
resolveEpubLocator,
|
|
createFullAudioBook,
|
|
regenerateChapter,
|
|
handleLocationChanged,
|
|
setRendition,
|
|
isAudioCombining,
|
|
highlightSegment,
|
|
clearHighlights,
|
|
highlightWordIndex,
|
|
clearWordHighlights,
|
|
]
|
|
);
|
|
|
|
return (
|
|
<EPUBContext.Provider value={contextValue}>
|
|
{children}
|
|
</EPUBContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Custom hook to consume the EPUB context
|
|
* @returns {EPUBContextType} The EPUB context value
|
|
* @throws {Error} When used outside of EPUBProvider
|
|
*/
|
|
export function useEPUB() {
|
|
const context = useContext(EPUBContext);
|
|
if (context === undefined) {
|
|
throw new Error('useEPUB must be used within an EPUBProvider');
|
|
}
|
|
return context;
|
|
}
|