openreader/src/contexts/EPUBContext.tsx
Richard R b4f4d43d6a feat(tts,config,types): migrate to providerRef/providerType model and add centralized TTS provider policy
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.
2026-05-13 10:11:05 -06:00

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;
}