Consolidate EPUB TTS segment planning around a single canonical windowing model. Introduce `epub-canonical-window.ts` to provide stable, viewport-independent segment windows for both playback and sidebar. Refactor segment planning, prefetching, and playback handoff to use canonical ordinals and keys, ensuring that segments straddling page breaks are uniquely identified and never replayed across pages. Update TTSContext, SegmentsSidebar, and related hooks to support windowed canonical segments and precise ordinal continuity. Extend types and interfaces to carry canonical segment metadata and windowing context. Add comprehensive unit tests for canonical window selection, segment materialization, and cache behavior. BREAKING CHANGE: EPUB TTS segment planning, playback, and sidebar now rely on canonical windowing, affecting segment key generation and playback logic.
3527 lines
125 KiB
TypeScript
3527 lines
125 KiB
TypeScript
/**
|
||
* Text-to-Speech (TTS) Context Provider
|
||
*
|
||
* This module provides a React context for managing text-to-speech functionality.
|
||
* It handles audio playback, sentence processing, and integration with OpenAI's TTS API.
|
||
*
|
||
* Key features:
|
||
* - Audio playback control (play/pause/skip)
|
||
* - Sentence-by-sentence processing
|
||
* - Audio caching for better performance
|
||
* - Voice and speed control
|
||
* - Document navigation
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import {
|
||
createContext,
|
||
useContext,
|
||
useState,
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useMemo,
|
||
ReactNode,
|
||
ReactElement
|
||
} from 'react';
|
||
import { Howl } from 'howler';
|
||
import toast from 'react-hot-toast';
|
||
import { useParams, usePathname } from 'next/navigation';
|
||
|
||
import { useConfig } from '@/contexts/ConfigContext';
|
||
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
||
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie';
|
||
import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
||
import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks';
|
||
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
|
||
import {
|
||
buildSegmentKeyPrefix,
|
||
planCanonicalTtsSegments,
|
||
type CanonicalTtsSegment,
|
||
type CanonicalTtsSourceUnit,
|
||
} from '@/lib/shared/tts-segment-plan';
|
||
import {
|
||
buildWalkerPlanningSourceUnits,
|
||
selectUpcomingWalkerItems,
|
||
} from '@/lib/client/epub/tts-epub-preload';
|
||
import {
|
||
releaseWarmAudio,
|
||
upsertWarmAudioEntry,
|
||
type WarmAudioCacheEntry,
|
||
} from '@/lib/client/tts/audio-warm-cache';
|
||
import {
|
||
isRetryableSegmentStatus,
|
||
resolveSegmentStatusRetryDelayMs,
|
||
shouldDeferSegmentRetry,
|
||
} from '@/lib/client/tts/segment-retry-policy';
|
||
import {
|
||
completedEpubBoundarySegment,
|
||
resolveEpubBoundaryHandoffStartIndex,
|
||
resolveEpubReplaySuppressionAction,
|
||
type CompletedEpubBoundarySegment,
|
||
} from '@/lib/client/epub/tts-epub-handoff';
|
||
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||
import { resolveTtsLanguage } from '@/lib/shared/language';
|
||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||
import type {
|
||
EpubRenderedLocationWalker,
|
||
TTSLocation,
|
||
TTSPageTurnEstimate,
|
||
TTSPlaybackState,
|
||
TTSSentenceAlignment,
|
||
} from '@/types/tts';
|
||
import type {
|
||
TTSRequestHeaders,
|
||
TTSSegmentInput,
|
||
TTSSegmentLocator,
|
||
TTSRetryOptions,
|
||
TTSSegmentManifestItem,
|
||
} from '@/types/client';
|
||
import { isStableEpubLocator } from '@/types/client';
|
||
|
||
/**
|
||
* Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub',
|
||
* location: <CFI> }`) into a stable book coordinate. The resolver lives in
|
||
* the route-local EPUB reader hook where the live `Book` instance is available; TTSContext calls it
|
||
* just before posting segments to the server so what gets persisted is
|
||
* viewport-independent. Returns null when the CFI can't be resolved.
|
||
*/
|
||
export type EpubLocatorResolver = (
|
||
draft: TTSSegmentLocator,
|
||
segmentText: string,
|
||
options?: {
|
||
segmentIndex: number;
|
||
segmentKey?: string | null;
|
||
keyPrefix: string;
|
||
maxBlockLength: number;
|
||
},
|
||
) => Promise<{
|
||
locator: TTSSegmentLocator;
|
||
segmentKey?: string | null;
|
||
segmentIndex?: number;
|
||
text?: string;
|
||
} | null>;
|
||
import type { ReaderType } from '@/types/user-state';
|
||
import {
|
||
clampSegmentPreloadDepth,
|
||
clampSegmentPreloadSentenceLookahead,
|
||
} from '@/types/config';
|
||
|
||
// Media globals
|
||
declare global {
|
||
interface Window {
|
||
webkitAudioContext: typeof AudioContext;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Interface defining all available methods and properties in the TTS context
|
||
*/
|
||
interface TTSContextType extends TTSPlaybackState {
|
||
// Voice settings
|
||
voice: string;
|
||
availableVoices: string[];
|
||
|
||
// Sentence/segment list and cursor (for the segments sidebar)
|
||
sentences: string[];
|
||
currentSentenceIndex: number;
|
||
|
||
// Alignment metadata for the current sentence
|
||
currentSentenceAlignment?: TTSSentenceAlignment;
|
||
currentWordIndex?: number | null;
|
||
|
||
// Control functions
|
||
togglePlay: () => void;
|
||
skipForward: () => void;
|
||
skipBackward: () => void;
|
||
pause: () => void;
|
||
stop: () => void;
|
||
stopAndPlayFromIndex: (index: number) => void;
|
||
playFromSegment: (index: number, locator?: TTSSegmentLocator | null) => void;
|
||
setText: (text: string, options?: boolean | SetTextOptions) => void;
|
||
setCurrDocPages: (num: number | undefined) => void;
|
||
setSpeedAndRestart: (speed: number) => void;
|
||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||
setVoiceAndRestart: (voice: string) => void;
|
||
documentLanguage: string;
|
||
resolvedLanguage: string;
|
||
setDocumentLanguage: (language: string) => void;
|
||
clearSegmentCaches: () => void;
|
||
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
|
||
registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation
|
||
registerEpubLocationWalker: (walker: EpubRenderedLocationWalker | null) => void;
|
||
registerEpubLocatorResolver: (resolver: EpubLocatorResolver | null) => void; // EPUB-only: resolves CFI drafts to stable spine coords before persist
|
||
registerVisualPageChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void;
|
||
setIsEPUB: (isEPUB: boolean) => void;
|
||
/** Effective reader type used to mint segmentKeys (see buildSegmentKeyPrefix). */
|
||
activeReaderType: ReaderType;
|
||
}
|
||
|
||
interface SetTextOptions {
|
||
shouldPause?: boolean;
|
||
location?: TTSLocation;
|
||
sourceUnits?: CanonicalTtsSourceUnit[];
|
||
previousLocation?: TTSLocation;
|
||
nextLocation?: TTSLocation;
|
||
nextText?: string;
|
||
nextSourceUnits?: CanonicalTtsSourceUnit[];
|
||
previousText?: string;
|
||
upcomingLocations?: Array<{ location: TTSLocation; text: string; sourceUnits?: CanonicalTtsSourceUnit[] }>;
|
||
/**
|
||
* EPUB canonical path: pre-windowed segments for the current page, sliced
|
||
* from the chapter's single canonical plan (stable key + global `ordinal`).
|
||
* When present, setText uses them verbatim and skips preview-based planning.
|
||
*/
|
||
canonicalSegments?: CanonicalTtsSegment[];
|
||
/** Spine identity of `canonicalSegments`, for the ordinal-continuity gate. */
|
||
canonicalSpine?: { spineHref: string; spineIndex: number };
|
||
/** Canonical segments for the next page, staged for the hidden-tab fast path. */
|
||
canonicalNextSegments?: CanonicalTtsSegment[];
|
||
}
|
||
|
||
type TTSSegmentPlaybackSource = {
|
||
presignUrl: string;
|
||
fallbackUrl: string;
|
||
manifest: TTSSegmentManifestItem;
|
||
};
|
||
|
||
type TTSPendingJumpTarget = {
|
||
locationKey: string;
|
||
index: number;
|
||
};
|
||
|
||
type EpubLocationPreloadCandidate = {
|
||
sentence: string;
|
||
segmentKey: string;
|
||
segmentIndex: number;
|
||
location: string;
|
||
requestKey: string;
|
||
cacheKey: string;
|
||
};
|
||
|
||
type PersistResolution = {
|
||
segments: TTSSegmentInput[];
|
||
sourceIndices: number[];
|
||
};
|
||
|
||
type JumpResolutionInput = {
|
||
isEPUB: boolean;
|
||
newSentenceCount: number;
|
||
resolvedLocationKey: string;
|
||
pendingEpubJump: { index: number; epoch: number } | null;
|
||
currentEpubEpoch: number;
|
||
pendingStrictJump: { locationKey: string; index: number } | null;
|
||
};
|
||
|
||
type JumpResolution =
|
||
| { kind: 'epub-resolved'; index: number }
|
||
| { kind: 'strict-resolved'; index: number }
|
||
| { kind: 'fresh' };
|
||
|
||
const LOOP_GUARD_MIN_INDEX = 2;
|
||
const LOOP_GUARD_MIN_PROGRESS = 0.6;
|
||
const AUDIO_CACHE_MAX_ITEMS = 25;
|
||
const WARM_AUDIO_CACHE_MAX_ITEMS = 6;
|
||
// Read once per module load from SSR-injected runtime config. This sits at
|
||
// module scope because the highlight pipeline is constructed lazily and the
|
||
// flag rarely changes within a session — admin toggling it picks up on
|
||
// reload, matching the SSR-injection model.
|
||
const wordHighlightFeatureEnabled = (() => {
|
||
if (typeof window === 'undefined') return true;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const injected = (window as any).__RUNTIME_CONFIG__;
|
||
if (!injected || typeof injected !== 'object') return true;
|
||
return typeof injected.computeAvailable === 'boolean'
|
||
? injected.computeAvailable
|
||
: true;
|
||
})();
|
||
|
||
// Tiny silent WAV used to unlock HTML5 audio on iOS/Safari.
|
||
const SILENT_WAV_DATA_URI =
|
||
'data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
|
||
|
||
const normalizeLocationKey = normalizeTtsLocationKey;
|
||
|
||
const normalizeBlockFingerprint = (text: string): string => {
|
||
const normalized = preprocessSentenceForAudio(text)
|
||
.normalize('NFKC')
|
||
.toLowerCase()
|
||
.replace(/[^\p{L}\p{N}\p{M}\s]/gu, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
return normalized.slice(0, 200);
|
||
};
|
||
|
||
const buildCacheKey = (
|
||
sentence: string,
|
||
voice: string,
|
||
speed: number,
|
||
provider: string,
|
||
model: string,
|
||
providerType: string,
|
||
instructions: string,
|
||
language: string,
|
||
) => {
|
||
return [
|
||
`provider=${provider || ''}`,
|
||
`providerType=${providerType || ''}`,
|
||
`model=${model || ''}`,
|
||
`voice=${voice || ''}`,
|
||
`speed=${Number.isFinite(speed) ? speed : ''}`,
|
||
`instructions=${instructions || ''}`,
|
||
`language=${language || ''}`,
|
||
`text=${sentence}`,
|
||
].join('|');
|
||
};
|
||
|
||
const buildScopedSegmentCacheKey = (
|
||
locator: TTSSegmentLocator,
|
||
segmentIndex: number,
|
||
sentence: string,
|
||
voice: string,
|
||
speed: number,
|
||
provider: string,
|
||
model: string,
|
||
providerType: string,
|
||
instructions: string,
|
||
language: string,
|
||
segmentKey?: string | null,
|
||
) => {
|
||
return [
|
||
buildCacheKey(sentence, voice, speed, provider, model, providerType, instructions, language),
|
||
`segmentKey=${segmentKey || ''}`,
|
||
`locator=${segmentKey ? '' : buildLocatorRequestKey(locator)}`,
|
||
`segmentIndex=${segmentKey ? '' : segmentIndex}`,
|
||
].join('|');
|
||
};
|
||
|
||
const buildLocatorRequestKey = (locator: TTSSegmentLocator): string => {
|
||
// Stable EPUB locators: use spine identity + charOffset for a unique,
|
||
// viewport-independent cache key. Falling through to `locator.location`
|
||
// would yield the empty string for the new shape and collide across
|
||
// segments.
|
||
if (locator.readerType === 'epub') {
|
||
if (
|
||
typeof locator.spineHref === 'string'
|
||
&& typeof locator.spineIndex === 'number'
|
||
&& typeof locator.charOffset === 'number'
|
||
) {
|
||
return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`;
|
||
}
|
||
if (typeof locator.cfi === 'string' && locator.cfi) {
|
||
return normalizeLocationKey(locator.cfi);
|
||
}
|
||
}
|
||
if (typeof locator.location === 'string' && locator.location) {
|
||
return normalizeLocationKey(locator.location);
|
||
}
|
||
if (locator.readerType === 'pdf') {
|
||
const page = Number(locator.page || 1);
|
||
const block = typeof locator.blockId === 'string' && locator.blockId ? locator.blockId : '';
|
||
return normalizeLocationKey(`pdf:${page}:${block}`);
|
||
}
|
||
return normalizeLocationKey(Number(locator.page || 1));
|
||
};
|
||
|
||
const buildSegmentRequestKey = (
|
||
locator: TTSSegmentLocator,
|
||
segmentIndex: number,
|
||
sentence: string,
|
||
segmentKey?: string | null,
|
||
): string => {
|
||
return segmentKey
|
||
? `${segmentKey}::${sentence}`
|
||
: `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`;
|
||
};
|
||
|
||
const resolveJumpIndex = (input: JumpResolutionInput): JumpResolution => {
|
||
if (input.newSentenceCount <= 0) {
|
||
return { kind: 'fresh' };
|
||
}
|
||
const clamp = (raw: number): number =>
|
||
Math.max(0, Math.min(raw, input.newSentenceCount - 1));
|
||
|
||
if (
|
||
input.isEPUB
|
||
&& input.pendingEpubJump
|
||
&& input.pendingEpubJump.epoch === input.currentEpubEpoch
|
||
) {
|
||
return { kind: 'epub-resolved', index: clamp(input.pendingEpubJump.index) };
|
||
}
|
||
|
||
if (
|
||
input.pendingStrictJump
|
||
&& input.pendingStrictJump.locationKey === input.resolvedLocationKey
|
||
) {
|
||
return { kind: 'strict-resolved', index: clamp(input.pendingStrictJump.index) };
|
||
}
|
||
|
||
return { kind: 'fresh' };
|
||
};
|
||
|
||
/**
|
||
* Resolve the local start index for an EPUB canonical-window page using ordinal
|
||
* continuity. The window's segments carry chapter-global `ordinal`s; we begin at
|
||
* the first one past the highest ordinal already spoken in this same spine item.
|
||
*
|
||
* Only trims on a *forward, contiguous* turn — when the window overlaps or sits
|
||
* just after what we've played. A backward turn or a non-contiguous jump returns
|
||
* 0 (play the whole window from the top); a different spine item returns 0.
|
||
*/
|
||
const resolveCanonicalStartIndex = (
|
||
segments: CanonicalTtsSegment[],
|
||
spine: { spineHref: string; spineIndex: number } | null,
|
||
lastPlayed: { spineHref: string; spineIndex: number; ordinal: number } | null,
|
||
): number => {
|
||
if (segments.length === 0) return 0;
|
||
if (!spine || !lastPlayed) return 0;
|
||
if (lastPlayed.spineHref !== spine.spineHref || lastPlayed.spineIndex !== spine.spineIndex) return 0;
|
||
|
||
const windowStart = segments[0].ordinal;
|
||
const windowEnd = segments[segments.length - 1].ordinal;
|
||
if (windowStart > lastPlayed.ordinal + 1) return 0; // jumped ahead → play whole window
|
||
if (windowEnd < lastPlayed.ordinal) return 0; // moved backward → play whole window
|
||
|
||
const idx = segments.findIndex((segment) => segment.ordinal > lastPlayed.ordinal);
|
||
return idx < 0 ? segments.length : idx;
|
||
};
|
||
|
||
const sourceKeyForLocation = (location: TTSLocation | undefined, fallback: TTSLocation): string =>
|
||
normalizeLocationKey(location ?? fallback);
|
||
|
||
const locatorForLocation = (
|
||
location: TTSLocation,
|
||
readerType: ReaderType,
|
||
): TTSSegmentLocator => {
|
||
if (typeof location === 'string') {
|
||
return { location, readerType };
|
||
}
|
||
if (readerType === 'html') {
|
||
return { location: String(location || 1), readerType };
|
||
}
|
||
return { page: Math.max(1, Math.floor(Number(location || 1))), readerType };
|
||
};
|
||
|
||
// Create the context
|
||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||
|
||
/**
|
||
* Main provider component that manages the TTS state and functionality.
|
||
* Handles initialization of OpenAI client, audio context, and media session.
|
||
*
|
||
* @param {Object} props - Component props
|
||
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
||
* @returns {JSX.Element} TTSProvider component
|
||
*/
|
||
export function TTSProvider({ children }: { children: ReactNode }): ReactElement {
|
||
// Configuration context consumption
|
||
const {
|
||
apiKey: openApiKey,
|
||
baseUrl: openApiBaseUrl,
|
||
isLoading: configIsLoading,
|
||
voiceSpeed,
|
||
audioPlayerSpeed,
|
||
voice: configVoice,
|
||
providerRef: configProviderRef,
|
||
providerType: configProviderType,
|
||
ttsModel: configTTSModel,
|
||
ttsInstructions: configTTSInstructions,
|
||
updateConfigKey,
|
||
skipBlank,
|
||
segmentPreloadDepthPages,
|
||
segmentPreloadSentenceLookahead,
|
||
ttsSegmentMaxBlockLength,
|
||
pdfHighlightEnabled,
|
||
pdfWordHighlightEnabled,
|
||
epubHighlightEnabled,
|
||
epubWordHighlightEnabled,
|
||
} = useConfig();
|
||
|
||
// Audio and voice management hooks
|
||
const audioContext = useAudioContext();
|
||
const { availableVoices, fetchVoices } = useVoiceManagement(
|
||
openApiKey,
|
||
openApiBaseUrl,
|
||
configProviderRef,
|
||
configProviderType,
|
||
configTTSModel,
|
||
);
|
||
const {
|
||
onTTSStart,
|
||
onTTSComplete,
|
||
refresh: refreshRateLimit,
|
||
triggerRateLimit,
|
||
isAtLimit,
|
||
} = useAuthRateLimit();
|
||
|
||
// Get document ID and reader type from URL
|
||
const { id } = useParams();
|
||
const pathname = usePathname();
|
||
const documentId = useMemo(() => {
|
||
if (typeof id === 'string') return id;
|
||
if (Array.isArray(id)) return id[0];
|
||
return '';
|
||
}, [id]);
|
||
|
||
const currentReaderType: ReaderType = useMemo(() => {
|
||
if (pathname.startsWith('/epub/')) return 'epub';
|
||
if (pathname.startsWith('/html/')) return 'html';
|
||
return 'pdf';
|
||
}, [pathname]);
|
||
|
||
// Add ref for location change handler
|
||
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||
const visualPageChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||
|
||
/**
|
||
* Registers a handler function for location changes in EPUB documents
|
||
* This is only used for EPUB documents to handle chapter navigation
|
||
*
|
||
* @param {Function} handler - Function to handle location changes
|
||
*/
|
||
const registerLocationChangeHandler = useCallback((handler: ((location: TTSLocation) => void) | null) => {
|
||
locationChangeHandlerRef.current = handler;
|
||
}, []);
|
||
|
||
const epubLocationWalkerRef = useRef<EpubRenderedLocationWalker | null>(null);
|
||
|
||
const registerEpubLocationWalker = useCallback((walker: EpubRenderedLocationWalker | null) => {
|
||
epubLocationWalkerRef.current = walker;
|
||
}, []);
|
||
|
||
/**
|
||
* Resolves a CFI + segment text into stable EPUB coordinates. Registered by
|
||
* the route-local EPUB reader hook (which owns the live `Book` instance). Used at server-persist
|
||
* time so the saved locator carries `spineHref`/`spineIndex`/`charOffset`
|
||
* rather than the viewport-dependent CFI string. Returns null when the CFI
|
||
* can't be resolved — callers should drop the segment from the persist
|
||
* payload in that case to avoid writing legacy-shape locators.
|
||
*/
|
||
const epubLocatorResolverRef = useRef<EpubLocatorResolver | null>(null);
|
||
|
||
const registerEpubLocatorResolver = useCallback((resolver: EpubLocatorResolver | null) => {
|
||
epubLocatorResolverRef.current = resolver;
|
||
}, []);
|
||
|
||
/**
|
||
* Walks the segment payload that's about to be POSTed to
|
||
* /api/tts/segments/ensure and canonicalizes EPUB entries through the
|
||
* registered resolver. This normalizes both draft and already-stable locators
|
||
* to a spine-level canonical segment identity (text/key/index/locator).
|
||
* Non-EPUB locators are untouched.
|
||
*
|
||
* Segments whose EPUB locators can't be resolved are DROPPED from the
|
||
* payload — we'd rather persist nothing than persist a viewport-dependent
|
||
* locator that will misbehave across devices and resizes.
|
||
*/
|
||
const resolveSegmentsForPersist = useCallback(async (
|
||
segments: TTSSegmentInput[],
|
||
): Promise<PersistResolution> => {
|
||
const resolver = epubLocatorResolverRef.current;
|
||
const out: TTSSegmentInput[] = [];
|
||
const sourceIndices: number[] = [];
|
||
for (let idx = 0; idx < segments.length; idx += 1) {
|
||
const segment = segments[idx];
|
||
const locator = segment.locator;
|
||
if (!locator || locator.readerType !== 'epub') {
|
||
out.push(segment);
|
||
sourceIndices.push(idx);
|
||
continue;
|
||
}
|
||
if (!resolver) {
|
||
// No book available to resolve — drop. This can happen during early
|
||
// boot before the EPUB reader hook has mounted.
|
||
continue;
|
||
}
|
||
const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub');
|
||
try {
|
||
const resolved = await resolver(locator, segment.text, {
|
||
segmentIndex: segment.segmentIndex,
|
||
segmentKey: segment.segmentKey ?? null,
|
||
keyPrefix,
|
||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||
});
|
||
if (resolved && isStableEpubLocator(resolved.locator)) {
|
||
out.push({
|
||
...segment,
|
||
locator: resolved.locator,
|
||
...(typeof resolved.segmentKey === 'string' && resolved.segmentKey.trim()
|
||
? { segmentKey: resolved.segmentKey.trim() }
|
||
: {}),
|
||
...(typeof resolved.segmentIndex === 'number' && Number.isFinite(resolved.segmentIndex)
|
||
? { segmentIndex: Math.max(0, Math.floor(resolved.segmentIndex)) }
|
||
: {}),
|
||
...(typeof resolved.text === 'string' && resolved.text.trim()
|
||
? { text: resolved.text }
|
||
: {}),
|
||
});
|
||
sourceIndices.push(idx);
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to resolve EPUB locator; dropping segment', error);
|
||
}
|
||
}
|
||
return {
|
||
segments: out,
|
||
sourceIndices,
|
||
};
|
||
}, [documentId, ttsSegmentMaxBlockLength]);
|
||
|
||
/**
|
||
* Registers a handler function for visual page changes in EPUB documents
|
||
* This is only used for EPUB documents to handle visual page navigation
|
||
*
|
||
* @param {Function} handler - Function to handle visual page changes
|
||
*/
|
||
const registerVisualPageChangeHandler = useCallback((handler: ((location: TTSLocation) => void) | null) => {
|
||
visualPageChangeHandlerRef.current = handler;
|
||
}, []);
|
||
|
||
/**
|
||
* State Management
|
||
*/
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [isEPUB, setIsEPUB] = useState(false);
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
|
||
/**
|
||
* Resolved reader type for segment planning. Mirrors the `activeReaderType`
|
||
* used inside `setText`/preload paths so external consumers (e.g. the
|
||
* sidebar) can compute identical `segmentKey`s from local text and match
|
||
* them against persisted manifest rows by content identity.
|
||
*/
|
||
const activeReaderType: ReaderType = useMemo(
|
||
() => (isEPUB ? 'epub' : currentReaderType),
|
||
[isEPUB, currentReaderType],
|
||
);
|
||
|
||
const [currDocPage, setCurrDocPage] = useState<TTSLocation>(1);
|
||
const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only
|
||
const [currDocPages, setCurrDocPages] = useState<number>();
|
||
|
||
const [sentences, setSentences] = useState<string[]>([]);
|
||
const [playbackSegments, setPlaybackSegments] = useState<CanonicalTtsSegment[]>([]);
|
||
const [currentIndex, setCurrentIndex] = useState(0);
|
||
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
|
||
const [speed, setSpeed] = useState(voiceSpeed);
|
||
const [audioSpeed, setAudioSpeed] = useState(audioPlayerSpeed);
|
||
const [voice, setVoice] = useState(configVoice);
|
||
const [ttsModel, setTTSModel] = useState(configTTSModel);
|
||
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
|
||
const [documentLanguage, setDocumentLanguage] = useState('auto');
|
||
const resolvedLanguage = useMemo(
|
||
() => resolveTtsLanguage({ configuredLanguage: documentLanguage, voice }),
|
||
[documentLanguage, voice],
|
||
);
|
||
const providerModelPolicy = useMemo(
|
||
() => resolveTtsProviderModelPolicy({
|
||
providerRef: configProviderRef,
|
||
providerType: configProviderType,
|
||
model: ttsModel,
|
||
}),
|
||
[configProviderRef, configProviderType, ttsModel],
|
||
);
|
||
const configModelPolicy = useMemo(
|
||
() => resolveTtsProviderModelPolicy({
|
||
providerRef: configProviderRef,
|
||
providerType: configProviderType,
|
||
model: configTTSModel,
|
||
}),
|
||
[configProviderRef, configProviderType, configTTSModel],
|
||
);
|
||
const effectiveNativeSpeed = useMemo(
|
||
() => (providerModelPolicy.supportsNativeModelSpeed ? speed : 1),
|
||
[providerModelPolicy.supportsNativeModelSpeed, speed],
|
||
);
|
||
|
||
// Track pending preload requests
|
||
const preloadRequests = useRef<Map<string, Promise<TTSSegmentPlaybackSource | null>>>(new Map());
|
||
const segmentRetryCooldownRef = useRef<Map<string, number>>(new Map());
|
||
const warmAudioCacheRef = useRef<Map<string, WarmAudioCacheEntry>>(new Map());
|
||
// Track active abort controllers for TTS requests
|
||
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
|
||
// Synchronous guard to prevent duplicate playAudio calls from the main playback effect.
|
||
// React state updates (isProcessing, activeHowl) are async, so between the effect firing
|
||
// and those guards taking effect, the effect can re-fire and start duplicate playback.
|
||
// This is especially problematic in Firefox where HTML5 Audio events can cause extra renders.
|
||
const playbackInFlightRef = useRef(false);
|
||
const playbackRunIdRef = useRef(0);
|
||
const pendingJumpTargetRef = useRef<TTSPendingJumpTarget | null>(null);
|
||
// EPUB-only jump resolution. epub.js navigation snaps CFIs to page-aligned
|
||
// values, so the strict locationKey match in pendingJumpTargetRef misses on
|
||
// cross-spine jumps. We instead bump an epoch on each playFromSegment call
|
||
// and let the next setText with a matching epoch consume the jump.
|
||
const pendingEpubJumpRef = useRef<{ index: number; epoch: number; charOffset?: number } | null>(null);
|
||
const epubJumpEpochRef = useRef<number>(0);
|
||
const epubPreloadGenerationRef = useRef<number>(0);
|
||
const epubWalkInFlightRef = useRef<Set<string>>(new Set());
|
||
// Guard to coalesce rapid restarts and only resume the latest change
|
||
const restartSeqRef = useRef(0);
|
||
// Preserve autoplay intent across location changes. Some browsers can emit pause
|
||
// events while we stop/unload between pages, which momentarily flips `isPlaying`
|
||
// false and can prevent automatic resume on the next page.
|
||
const resumeAfterLocationChangeRef = useRef(false);
|
||
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
|
||
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const pageFirstBlockFingerprintRef = useRef<Map<string, string>>(new Map());
|
||
const sentenceAlignmentCacheRef = useRef<Map<string, TTSSentenceAlignment>>(new Map());
|
||
const segmentManifestCacheRef = useRef<Map<string, TTSSegmentManifestItem>>(new Map());
|
||
const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState<TTSSentenceAlignment | undefined>();
|
||
const [currentWordIndex, setCurrentWordIndex] = useState<number | null>(null);
|
||
const isPlayingRef = useRef(false);
|
||
const pauseEpochRef = useRef(0);
|
||
const pendingPlaybackStartRef = useRef(false);
|
||
const sentencesRef = useRef<string[]>([]);
|
||
const currentIndexRef = useRef(0);
|
||
const plannedSegmentsByLocationRef = useRef<Map<string, CanonicalTtsSegment[]>>(new Map());
|
||
const currentSourceUnitRef = useRef<CanonicalTtsSourceUnit | null>(null);
|
||
const currentSourceContextUnitsRef = useRef<CanonicalTtsSourceUnit[]>([]);
|
||
const completedEpubBoundarySegmentRef = useRef<CompletedEpubBoundarySegment | null>(null);
|
||
// Highest canonical segment ordinal already spoken in the current spine item.
|
||
// Drives exact, viewport-independent page-turn handoff: the next page begins
|
||
// at the first window segment whose ordinal exceeds this. Replaces fuzzy
|
||
// fingerprint matching for within-chapter turns (the spine→spine handoff in
|
||
// tts-epub-handoff.ts remains the fallback path's safety net).
|
||
const lastPlayedCanonicalRef = useRef<{ spineHref: string; spineIndex: number; ordinal: number } | null>(null);
|
||
const pendingNextLocationRef = useRef<TTSLocation | undefined>(undefined);
|
||
|
||
const audioUnlockAttemptRef = useRef(0);
|
||
|
||
// Safari/iOS (HTML5 audio) can spontaneously reset playbackRate to 1. Keep re-applying
|
||
// the desired rate while a sentence is playing.
|
||
const rateWatchdogIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
const clearRateWatchdog = useCallback(() => {
|
||
if (!rateWatchdogIntervalRef.current) return;
|
||
clearInterval(rateWatchdogIntervalRef.current);
|
||
rateWatchdogIntervalRef.current = null;
|
||
}, []);
|
||
|
||
const clearWarmAudioCache = useCallback(() => {
|
||
warmAudioCacheRef.current.forEach((entry) => {
|
||
releaseWarmAudio(entry.audio);
|
||
});
|
||
warmAudioCacheRef.current.clear();
|
||
}, []);
|
||
|
||
const warmSegmentAudioUrl = useCallback((requestKey: string, primaryUrl: string | null, fallbackUrl: string | null) => {
|
||
const candidateUrl = primaryUrl || fallbackUrl;
|
||
if (!candidateUrl) return;
|
||
if (typeof window === 'undefined' || typeof Audio === 'undefined') return;
|
||
|
||
upsertWarmAudioEntry({
|
||
key: requestKey,
|
||
url: candidateUrl,
|
||
cache: warmAudioCacheRef.current,
|
||
maxEntries: WARM_AUDIO_CACHE_MAX_ITEMS,
|
||
createAudio: (url) => {
|
||
const audio = new Audio(url);
|
||
audio.preload = 'auto';
|
||
audio.load();
|
||
return audio;
|
||
},
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => () => {
|
||
clearWarmAudioCache();
|
||
}, [clearWarmAudioCache]);
|
||
|
||
const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => {
|
||
if (!howl) return;
|
||
|
||
try {
|
||
howl.rate(audioSpeed);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
// Best-effort: Howler doesn't expose the underlying HTMLAudioElement publicly.
|
||
// This helps on browsers that reset playbackRate/defaultPlaybackRate.
|
||
try {
|
||
const sounds = (howl as unknown as { _sounds?: Array<{ _node?: unknown }> })._sounds;
|
||
const node = sounds?.[0]?._node as unknown;
|
||
if (node && typeof node === 'object') {
|
||
const anyNode = node as { playbackRate?: number; defaultPlaybackRate?: number };
|
||
if (typeof anyNode.playbackRate === 'number') anyNode.playbackRate = audioSpeed;
|
||
if (typeof anyNode.defaultPlaybackRate === 'number') anyNode.defaultPlaybackRate = audioSpeed;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [audioSpeed]);
|
||
|
||
const startRateWatchdog = useCallback((howl: Howl | null) => {
|
||
if (!howl) return;
|
||
clearRateWatchdog();
|
||
|
||
// Apply immediately + keep applying while playback is active.
|
||
applyPlaybackRateToHowl(howl);
|
||
rateWatchdogIntervalRef.current = setInterval(() => {
|
||
applyPlaybackRateToHowl(howl);
|
||
}, 250);
|
||
}, [applyPlaybackRateToHowl, clearRateWatchdog]);
|
||
|
||
const unlockPlaybackOnUserGesture = useCallback(() => {
|
||
// Best-effort; safe to call multiple times.
|
||
audioUnlockAttemptRef.current += 1;
|
||
const attempt = audioUnlockAttemptRef.current;
|
||
|
||
try {
|
||
void audioContext?.resume();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
try {
|
||
const el = new Audio(SILENT_WAV_DATA_URI);
|
||
try {
|
||
el.setAttribute('playsinline', 'true');
|
||
} catch {
|
||
// ignore
|
||
}
|
||
el.preload = 'auto';
|
||
el.volume = 0;
|
||
|
||
const p = el.play();
|
||
if (p && typeof (p as Promise<void>).then === 'function') {
|
||
void (p as Promise<void>)
|
||
.then(() => {
|
||
if (audioUnlockAttemptRef.current !== attempt) return;
|
||
try {
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// ignore
|
||
});
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [audioContext]);
|
||
|
||
const setSegmentManifestCache = useCallback((key: string, item: TTSSegmentManifestItem) => {
|
||
const cache = segmentManifestCacheRef.current;
|
||
if (cache.has(key)) {
|
||
cache.delete(key);
|
||
}
|
||
cache.set(key, item);
|
||
while (cache.size > AUDIO_CACHE_MAX_ITEMS) {
|
||
const oldestKey = cache.keys().next().value;
|
||
if (typeof oldestKey !== 'string') break;
|
||
cache.delete(oldestKey);
|
||
}
|
||
}, []);
|
||
|
||
const cacheCompletedManifestForCandidate = useCallback((
|
||
cacheKey: string,
|
||
segment: TTSSegmentManifestItem,
|
||
alignmentEnabledForCurrentDoc: boolean,
|
||
): boolean => {
|
||
if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) {
|
||
return false;
|
||
}
|
||
setSegmentManifestCache(cacheKey, segment);
|
||
if (alignmentEnabledForCurrentDoc && segment.alignment) {
|
||
sentenceAlignmentCacheRef.current.set(cacheKey, segment.alignment);
|
||
}
|
||
return true;
|
||
}, [setSegmentManifestCache]);
|
||
|
||
const setSegmentRetryCooldown = useCallback((cacheKey: string, delayMs: number) => {
|
||
const now = Date.now();
|
||
const retryAtMs = now + Math.max(0, delayMs);
|
||
const cooldowns = segmentRetryCooldownRef.current;
|
||
cooldowns.set(cacheKey, retryAtMs);
|
||
while (cooldowns.size > AUDIO_CACHE_MAX_ITEMS) {
|
||
const oldestKey = cooldowns.keys().next().value;
|
||
if (typeof oldestKey !== 'string') break;
|
||
cooldowns.delete(oldestKey);
|
||
}
|
||
}, []);
|
||
|
||
const waitForRetryDelayWithAbort = useCallback((delayMs: number, signal: AbortSignal): Promise<void> => {
|
||
if (!Number.isFinite(delayMs) || delayMs <= 0) return Promise.resolve();
|
||
if (signal.aborted) {
|
||
return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||
}
|
||
return new Promise<void>((resolve, reject) => {
|
||
const timeout = setTimeout(() => {
|
||
signal.removeEventListener('abort', onAbort);
|
||
resolve();
|
||
}, delayMs);
|
||
const onAbort = () => {
|
||
clearTimeout(timeout);
|
||
signal.removeEventListener('abort', onAbort);
|
||
reject(new DOMException('Aborted', 'AbortError'));
|
||
};
|
||
signal.addEventListener('abort', onAbort, { once: true });
|
||
});
|
||
}, []);
|
||
|
||
const invalidatePlaybackRun = useCallback(() => {
|
||
playbackRunIdRef.current += 1;
|
||
playbackInFlightRef.current = false;
|
||
epubPreloadGenerationRef.current += 1;
|
||
epubWalkInFlightRef.current.clear();
|
||
}, []);
|
||
|
||
const bumpEpubPreloadGeneration = useCallback(() => {
|
||
epubPreloadGenerationRef.current += 1;
|
||
epubWalkInFlightRef.current.clear();
|
||
}, []);
|
||
|
||
const clearPendingEpubJump = useCallback(() => {
|
||
pendingEpubJumpRef.current = null;
|
||
epubJumpEpochRef.current += 1;
|
||
}, []);
|
||
|
||
const isAutoplayBlockedError = useCallback((err: unknown) => {
|
||
const msg = (() => {
|
||
if (typeof err === 'string') return err;
|
||
if (err instanceof Error) return err.message;
|
||
if (typeof err === 'object' && err !== null && 'message' in err) {
|
||
const maybe = (err as { message?: unknown }).message;
|
||
if (typeof maybe === 'string') return maybe;
|
||
}
|
||
return '';
|
||
})();
|
||
|
||
return /notallowed|not allowed|user gesture|interaction|autoplay|play\(\) failed/i.test(msg);
|
||
}, []);
|
||
|
||
const isAbortLikeError = useCallback((err: unknown): boolean => {
|
||
if (err instanceof Error) {
|
||
return err.name === 'AbortError' || /abort|cancel/i.test(err.message || '');
|
||
}
|
||
if (typeof err === 'string') {
|
||
return /abort|cancel/i.test(err);
|
||
}
|
||
if (typeof err === 'object' && err !== null && 'message' in err) {
|
||
const maybe = (err as { message?: unknown }).message;
|
||
if (typeof maybe === 'string') {
|
||
return /abort|cancel/i.test(maybe);
|
||
}
|
||
}
|
||
return false;
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
sentencesRef.current = sentences;
|
||
}, [sentences]);
|
||
|
||
useEffect(() => {
|
||
currentIndexRef.current = currentIndex;
|
||
}, [currentIndex]);
|
||
|
||
useEffect(() => {
|
||
isPlayingRef.current = isPlaying;
|
||
}, [isPlaying]);
|
||
|
||
/**
|
||
* Stops the current audio playback and clears the active Howl instance
|
||
* @param {boolean} [clearPending=false] - Whether to clear pending requests
|
||
*/
|
||
const abortAudio = useCallback((clearPending = false) => {
|
||
// Ensure next playback attempt is not blocked by a stale in-flight guard.
|
||
invalidatePlaybackRun();
|
||
clearRateWatchdog();
|
||
if (activeHowl) {
|
||
activeHowl.stop();
|
||
activeHowl.unload();
|
||
setActiveHowl(null);
|
||
}
|
||
|
||
if (clearPending) {
|
||
activeAbortControllers.current.forEach(controller => {
|
||
controller.abort();
|
||
});
|
||
activeAbortControllers.current.clear();
|
||
preloadRequests.current.clear();
|
||
segmentRetryCooldownRef.current.clear();
|
||
clearWarmAudioCache();
|
||
}
|
||
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
pageTurnTimeoutRef.current = null;
|
||
}
|
||
setCurrentWordIndex(null);
|
||
}, [activeHowl, clearRateWatchdog, clearWarmAudioCache, invalidatePlaybackRun]);
|
||
|
||
/**
|
||
* Pauses the current audio playback while preserving seek position.
|
||
*/
|
||
const pauseActiveHowl = useCallback(() => {
|
||
clearRateWatchdog();
|
||
if (activeHowl) {
|
||
try {
|
||
activeHowl.pause();
|
||
} catch (error) {
|
||
console.warn('Error pausing audio:', error);
|
||
}
|
||
}
|
||
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
pageTurnTimeoutRef.current = null;
|
||
}
|
||
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.playbackState = 'paused';
|
||
}
|
||
}, [activeHowl, clearRateWatchdog]);
|
||
|
||
const abortPendingTtsRequests = useCallback((clearPreloadRequests = false) => {
|
||
activeAbortControllers.current.forEach((controller) => {
|
||
controller.abort();
|
||
});
|
||
activeAbortControllers.current.clear();
|
||
if (clearPreloadRequests) {
|
||
preloadRequests.current.clear();
|
||
}
|
||
}, []);
|
||
|
||
const recordManualPause = useCallback(() => {
|
||
// Cancel any queued auto-resume intent and mark an explicit user pause.
|
||
resumeAfterLocationChangeRef.current = false;
|
||
pendingPlaybackStartRef.current = false;
|
||
pauseEpochRef.current += 1;
|
||
}, []);
|
||
|
||
/**
|
||
* Pauses the current audio playback
|
||
* Used for external control of playback state
|
||
*/
|
||
const pause = useCallback(() => {
|
||
pendingPlaybackStartRef.current = false;
|
||
recordManualPause();
|
||
clearPendingEpubJump();
|
||
abortPendingTtsRequests(true);
|
||
pauseActiveHowl();
|
||
setIsPlaying(false);
|
||
}, [pauseActiveHowl, recordManualPause, clearPendingEpubJump, abortPendingTtsRequests]);
|
||
|
||
/**
|
||
* Navigates to a specific location in the document
|
||
* Works for both PDF pages and EPUB locations
|
||
*
|
||
* @param {string | number} location - The target location to navigate to
|
||
* @param {boolean} shouldPause - Whether to pause playback
|
||
*/
|
||
const skipToLocation = useCallback((location: TTSLocation, shouldPause = false) => {
|
||
if (shouldPause) {
|
||
resumeAfterLocationChangeRef.current = false;
|
||
} else if (isPlayingRef.current) {
|
||
resumeAfterLocationChangeRef.current = true;
|
||
}
|
||
|
||
// Reset state for new content in correct order
|
||
invalidatePlaybackRun();
|
||
abortAudio(true);
|
||
if (shouldPause) setIsPlaying(false);
|
||
setCurrentIndex(0);
|
||
setSentences([]);
|
||
setPlaybackSegments([]);
|
||
setCurrDocPage(location);
|
||
|
||
}, [abortAudio, invalidatePlaybackRun]);
|
||
|
||
/**
|
||
* Moves to the next or previous sentence
|
||
*
|
||
* @param {boolean} [backwards=false] - Whether to move backwards
|
||
*/
|
||
const advance = useCallback(async (backwards = false) => {
|
||
const nextIndex = currentIndex + (backwards ? -1 : 1);
|
||
const movingForward = !backwards && nextIndex >= sentences.length;
|
||
|
||
// Handle within current page bounds
|
||
if (nextIndex < sentences.length && nextIndex >= 0) {
|
||
if (
|
||
!isEPUB &&
|
||
!backwards &&
|
||
currDocPages !== undefined &&
|
||
currDocPageNumber < currDocPages &&
|
||
nextIndex >= LOOP_GUARD_MIN_INDEX &&
|
||
sentences.length > 0
|
||
) {
|
||
const locationKey = normalizeLocationKey(currDocPageNumber);
|
||
const cachedFirstFingerprint = pageFirstBlockFingerprintRef.current.get(locationKey)
|
||
?? normalizeBlockFingerprint(sentences[0] || '');
|
||
const nextFingerprint = normalizeBlockFingerprint(sentences[nextIndex] || '');
|
||
const progress = sentences.length > 1 ? nextIndex / (sentences.length - 1) : 0;
|
||
|
||
if (
|
||
cachedFirstFingerprint &&
|
||
nextFingerprint &&
|
||
cachedFirstFingerprint === nextFingerprint &&
|
||
progress >= LOOP_GUARD_MIN_PROGRESS
|
||
) {
|
||
const targetLocation = currDocPageNumber + 1;
|
||
plannedSegmentsByLocationRef.current.delete(normalizeLocationKey(targetLocation));
|
||
pendingNextLocationRef.current = targetLocation;
|
||
skipToLocation(targetLocation);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setCurrentIndex(nextIndex);
|
||
return;
|
||
}
|
||
|
||
// For EPUB documents, always try to advance to next/prev section
|
||
if (isEPUB && locationChangeHandlerRef.current) {
|
||
if (movingForward && typeof document !== 'undefined' && document.hidden) {
|
||
const targetLocation = pendingNextLocationRef.current;
|
||
if (targetLocation !== undefined) {
|
||
const bufferKey = normalizeLocationKey(targetLocation);
|
||
const prefetchedSegments = plannedSegmentsByLocationRef.current.get(bufferKey);
|
||
if (prefetchedSegments?.length) {
|
||
plannedSegmentsByLocationRef.current.delete(bufferKey);
|
||
pendingNextLocationRef.current = undefined;
|
||
setCurrDocPage(targetLocation);
|
||
setPlaybackSegments(prefetchedSegments);
|
||
setSentences(prefetchedSegments.map((segment) => segment.text));
|
||
setCurrentIndex(0);
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
// Ask the viewer to continue turning pages/sections; this may be deferred while hidden.
|
||
locationChangeHandlerRef.current('next');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
const direction = nextIndex >= sentences.length ? 'next' : 'prev';
|
||
// EPUB navigation is asynchronous (rendition.next/prev → relocated →
|
||
// skipToLocation → setText), unlike PDF which resets synchronously via
|
||
// skipToLocation right here. Without clearing the just-finished page now,
|
||
// an unrelated re-render during the async gap — e.g. setActiveHowl(null)
|
||
// in onend changing playAudio's identity — re-fires the playback effect
|
||
// against the *stale* last index and replays the final segment before the
|
||
// next page loads. Reset synchronously to get PDF's deterministic handoff.
|
||
if (isPlayingRef.current) {
|
||
resumeAfterLocationChangeRef.current = true;
|
||
}
|
||
if (backwards) {
|
||
// Backward navigation breaks forward ordinal continuity.
|
||
lastPlayedCanonicalRef.current = null;
|
||
}
|
||
invalidatePlaybackRun();
|
||
setCurrentIndex(0);
|
||
setSentences([]);
|
||
setPlaybackSegments([]);
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
locationChangeHandlerRef.current(direction);
|
||
return;
|
||
}
|
||
|
||
// For PDFs and other documents, check page bounds
|
||
if (!isEPUB) {
|
||
// The HTML reader treats the entire document as a single page, so
|
||
// `currDocPages` is left `undefined`. In that mode there's nowhere to
|
||
// turn to — when we run past the last sentence, just stop playback.
|
||
// (We do this before the page-bound checks below so the `< undefined`
|
||
// / `>= undefined` NaN comparisons don't silently swallow the end.)
|
||
if (currDocPages === undefined) {
|
||
if (nextIndex >= sentences.length) {
|
||
setIsPlaying(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Handle next/previous page transitions
|
||
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
|
||
(nextIndex < 0 && currDocPageNumber > 1)) {
|
||
const targetLocation = currDocPageNumber + (nextIndex >= sentences.length ? 1 : -1);
|
||
|
||
// In background tabs, page text extraction can be delayed. If we already have
|
||
// prefetched text for the target page, keep speaking without waiting for viewer callbacks.
|
||
if (movingForward && typeof document !== 'undefined' && document.hidden) {
|
||
const bufferKey = normalizeLocationKey(targetLocation);
|
||
const prefetchedSegments = plannedSegmentsByLocationRef.current.get(bufferKey);
|
||
if (prefetchedSegments?.length) {
|
||
plannedSegmentsByLocationRef.current.delete(bufferKey);
|
||
pendingNextLocationRef.current = undefined;
|
||
setCurrDocPage(targetLocation);
|
||
setPlaybackSegments(prefetchedSegments);
|
||
setSentences(prefetchedSegments.map((segment) => segment.text));
|
||
setCurrentIndex(0);
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Pass wasPlaying to maintain playback state during page turn
|
||
skipToLocation(targetLocation);
|
||
return;
|
||
}
|
||
|
||
// Handle end of document (PDF only)
|
||
if (nextIndex >= sentences.length && currDocPageNumber >= currDocPages!) {
|
||
setIsPlaying(false);
|
||
}
|
||
}
|
||
}, [currentIndex, sentences, currDocPageNumber, currDocPages, isEPUB, skipToLocation, invalidatePlaybackRun]);
|
||
|
||
/**
|
||
* Handles blank text sections based on document type
|
||
*
|
||
* @param {string[]} sentences - Array of processed sentences
|
||
* @returns {boolean} - True if blank section was handled
|
||
*/
|
||
const handleBlankSection = useCallback((text: string): boolean => {
|
||
if (!isPlaying || !skipBlank || text.length > 0) {
|
||
return false;
|
||
}
|
||
|
||
// Use advance to handle navigation for both EPUB and PDF
|
||
advance();
|
||
|
||
toast.success(isEPUB ? 'Skipping blank section' : `Skipping blank page ${currDocPageNumber}`, {
|
||
id: isEPUB ? `epub-section-skip` : `page-${currDocPageNumber}`,
|
||
style: {
|
||
background: 'var(--background)',
|
||
color: 'var(--accent)',
|
||
},
|
||
duration: 1000,
|
||
position: 'top-center',
|
||
});
|
||
|
||
return true;
|
||
}, [isPlaying, skipBlank, advance, isEPUB, currDocPageNumber]);
|
||
|
||
/**
|
||
* Sets the current text and splits it into sentences
|
||
*
|
||
* @param {string} text - The text to be processed
|
||
*/
|
||
const setText = useCallback((text: string, options?: boolean | SetTextOptions) => {
|
||
const normalizedOptions: SetTextOptions = typeof options === 'boolean'
|
||
? { shouldPause: options }
|
||
: (options || {});
|
||
|
||
const resolvedLocation = normalizedOptions.location !== undefined
|
||
? normalizedOptions.location
|
||
: currDocPage;
|
||
const resolvedLocationKey = normalizeLocationKey(resolvedLocation);
|
||
const currentUnits = normalizedOptions.sourceUnits && normalizedOptions.sourceUnits.length > 0
|
||
? normalizedOptions.sourceUnits
|
||
: null;
|
||
|
||
// Keep currDocPage aligned with whatever the caller declared as the viewport's
|
||
// location. This is the canonical entry point for "the rendered page now shows
|
||
// this content at this location" — the navigation flow (handleLocationChanged →
|
||
// skipToLocation) already set it before calling setText, so this is a no-op
|
||
// for next/prev/jump. The path that needs it is **resize**: EPUBViewer's
|
||
// checkResize calls extractPageText directly (bypassing skipToLocation), so
|
||
// without this, currDocPage would stay pinned to the pre-resize CFI even
|
||
// though the page has repaginated to a new start CFI.
|
||
if (normalizedOptions.location !== undefined && normalizedOptions.location !== currDocPage) {
|
||
setCurrDocPage(normalizedOptions.location);
|
||
}
|
||
const currentSourceKey = sourceKeyForLocation(resolvedLocation, currDocPage);
|
||
const currentSource: CanonicalTtsSourceUnit = {
|
||
sourceKey: currentSourceKey,
|
||
text,
|
||
locator: locatorForLocation(resolvedLocation, activeReaderType),
|
||
};
|
||
const effectiveCurrentUnits = currentUnits && currentUnits.length > 0 ? currentUnits : [currentSource];
|
||
const currentSourceKeySet = new Set(effectiveCurrentUnits.map((unit) => unit.sourceKey));
|
||
|
||
// EPUB canonical path: the caller already windowed this page out of the
|
||
// chapter's single canonical plan, so we use those segments verbatim and
|
||
// skip preview-based planning entirely. This is what makes a page-straddling
|
||
// block identical (same key + global ordinal) on both pages.
|
||
const useCanonicalEpub = isEPUB
|
||
&& Array.isArray(normalizedOptions.canonicalSegments)
|
||
&& normalizedOptions.canonicalSegments.length > 0;
|
||
|
||
let currentSegments: CanonicalTtsSegment[];
|
||
let newSentences: string[];
|
||
|
||
if (useCanonicalEpub) {
|
||
currentSegments = normalizedOptions.canonicalSegments!;
|
||
newSentences = currentSegments.map((segment) => segment.text);
|
||
|
||
plannedSegmentsByLocationRef.current.clear();
|
||
pendingNextLocationRef.current = normalizedOptions.nextLocation;
|
||
// Stage the next page's canonical segments for the hidden-tab fast path.
|
||
if (
|
||
normalizedOptions.nextLocation !== undefined
|
||
&& normalizedOptions.canonicalNextSegments
|
||
&& normalizedOptions.canonicalNextSegments.length > 0
|
||
) {
|
||
plannedSegmentsByLocationRef.current.set(
|
||
normalizeLocationKey(normalizedOptions.nextLocation),
|
||
normalizedOptions.canonicalNextSegments,
|
||
);
|
||
}
|
||
// The walker reads these for preview fallback only; the canonical path
|
||
// attaches its own per-chunk segments, so clear them.
|
||
currentSourceUnitRef.current = null;
|
||
currentSourceContextUnitsRef.current = [];
|
||
} else {
|
||
const contextSourceUnits: CanonicalTtsSourceUnit[] = [];
|
||
if (normalizedOptions.previousText?.trim()) {
|
||
const previousLocation = normalizedOptions.previousLocation;
|
||
contextSourceUnits.push({
|
||
sourceKey: previousLocation !== undefined
|
||
? sourceKeyForLocation(previousLocation, currDocPage)
|
||
: `previous:${currentSourceKey}`,
|
||
text: normalizedOptions.previousText,
|
||
locator: previousLocation !== undefined
|
||
? locatorForLocation(previousLocation, activeReaderType)
|
||
: null,
|
||
});
|
||
}
|
||
contextSourceUnits.push(...effectiveCurrentUnits);
|
||
const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits];
|
||
|
||
plannedSegmentsByLocationRef.current.clear();
|
||
pendingNextLocationRef.current = normalizedOptions.nextLocation;
|
||
const pendingPrefetches: Array<{
|
||
location: TTSLocation;
|
||
sourceUnits: CanonicalTtsSourceUnit[];
|
||
}> = [];
|
||
if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) {
|
||
const provided = normalizedOptions.nextSourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? [];
|
||
pendingPrefetches.push(provided.length > 0
|
||
? {
|
||
location: normalizedOptions.nextLocation,
|
||
sourceUnits: provided,
|
||
}
|
||
: {
|
||
location: normalizedOptions.nextLocation,
|
||
sourceUnits: [{
|
||
sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage),
|
||
text: normalizedOptions.nextText,
|
||
locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType),
|
||
}],
|
||
});
|
||
}
|
||
if (Array.isArray(normalizedOptions.upcomingLocations)) {
|
||
for (const item of normalizedOptions.upcomingLocations) {
|
||
if (item.location === undefined || !item.text?.trim()) continue;
|
||
const provided = item.sourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? [];
|
||
pendingPrefetches.push(provided.length > 0
|
||
? {
|
||
location: item.location,
|
||
sourceUnits: provided,
|
||
}
|
||
: {
|
||
location: item.location,
|
||
sourceUnits: [{
|
||
sourceKey: sourceKeyForLocation(item.location, currDocPage),
|
||
text: item.text,
|
||
locator: locatorForLocation(item.location, activeReaderType),
|
||
}],
|
||
});
|
||
}
|
||
}
|
||
for (const item of pendingPrefetches) {
|
||
sourceUnits.push(...item.sourceUnits);
|
||
}
|
||
|
||
const plan = planCanonicalTtsSegments(sourceUnits, {
|
||
readerType: activeReaderType,
|
||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||
enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0,
|
||
language: resolvedLanguage,
|
||
});
|
||
currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey));
|
||
newSentences = currentSegments.map((segment) => segment.text);
|
||
|
||
for (const item of pendingPrefetches) {
|
||
const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey));
|
||
const planned = plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey));
|
||
if (planned.length > 0) {
|
||
plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned);
|
||
}
|
||
}
|
||
|
||
currentSourceUnitRef.current = effectiveCurrentUnits[0] ?? null;
|
||
currentSourceContextUnitsRef.current = contextSourceUnits;
|
||
}
|
||
|
||
if (handleBlankSection(newSentences.join(' '))) return;
|
||
|
||
const shouldPause = normalizedOptions.shouldPause ?? false;
|
||
const pauseEpochAtStart = pauseEpochRef.current;
|
||
const pendingAutoResume = resumeAfterLocationChangeRef.current;
|
||
const pendingPlaybackStart = pendingPlaybackStartRef.current;
|
||
const shouldResumePlayback = !shouldPause && (isPlaying || pendingAutoResume || pendingPlaybackStart);
|
||
|
||
// Keep track of previous state and pause playback
|
||
invalidatePlaybackRun();
|
||
setIsPlaying(false);
|
||
abortAudio(true); // Clear pending requests since text is changing
|
||
setIsProcessing(true); // Set processing state before text processing starts
|
||
|
||
try {
|
||
if (newSentences.length === 0) {
|
||
console.warn('No sentences found in text');
|
||
if (shouldPause || pendingAutoResume) {
|
||
resumeAfterLocationChangeRef.current = false;
|
||
}
|
||
if (pendingPlaybackStart) {
|
||
pendingPlaybackStartRef.current = false;
|
||
}
|
||
setIsProcessing(false);
|
||
return;
|
||
}
|
||
|
||
if (shouldPause || pendingAutoResume) {
|
||
resumeAfterLocationChangeRef.current = false;
|
||
}
|
||
if (pendingPlaybackStart) {
|
||
pendingPlaybackStartRef.current = false;
|
||
}
|
||
|
||
if (!isEPUB && typeof resolvedLocation === 'number') {
|
||
const firstFingerprint = normalizeBlockFingerprint(newSentences[0] || '');
|
||
if (firstFingerprint) {
|
||
pageFirstBlockFingerprintRef.current.set(
|
||
normalizeLocationKey(resolvedLocation),
|
||
firstFingerprint
|
||
);
|
||
}
|
||
}
|
||
|
||
setPlaybackSegments(currentSegments);
|
||
setSentences(newSentences);
|
||
|
||
const resolution = resolveJumpIndex({
|
||
isEPUB,
|
||
newSentenceCount: newSentences.length,
|
||
resolvedLocationKey,
|
||
pendingEpubJump: pendingEpubJumpRef.current,
|
||
currentEpubEpoch: epubJumpEpochRef.current,
|
||
pendingStrictJump: pendingJumpTargetRef.current,
|
||
});
|
||
let startIndex = 0;
|
||
if (resolution.kind === 'epub-resolved') {
|
||
startIndex = resolution.index;
|
||
// Cross-page jump hardening: the raw index was computed against the
|
||
// sidebar's view. On the canonical path, re-anchor to the segment whose
|
||
// per-segment charOffset matches the jump target so chapters with
|
||
// repeated text land exactly.
|
||
const jump = pendingEpubJumpRef.current;
|
||
if (useCanonicalEpub && jump && typeof jump.charOffset === 'number') {
|
||
const mapped = currentSegments.findIndex(
|
||
(segment) => segment.ownerLocator?.charOffset === jump.charOffset,
|
||
);
|
||
if (mapped >= 0) startIndex = mapped;
|
||
}
|
||
setCurrentIndex(startIndex);
|
||
pendingEpubJumpRef.current = null;
|
||
pendingJumpTargetRef.current = null;
|
||
} else if (resolution.kind === 'strict-resolved') {
|
||
startIndex = resolution.index;
|
||
setCurrentIndex(startIndex);
|
||
pendingJumpTargetRef.current = null;
|
||
} else {
|
||
if (isEPUB) {
|
||
clearPendingEpubJump();
|
||
}
|
||
if (useCanonicalEpub && shouldResumePlayback) {
|
||
// Exact ordinal handoff: start at the first window segment past the
|
||
// highest ordinal already spoken in this chapter.
|
||
startIndex = resolveCanonicalStartIndex(
|
||
currentSegments,
|
||
normalizedOptions.canonicalSpine ?? null,
|
||
lastPlayedCanonicalRef.current,
|
||
);
|
||
} else if (isEPUB && shouldResumePlayback) {
|
||
startIndex = resolveEpubBoundaryHandoffStartIndex(currentSegments, completedEpubBoundarySegmentRef.current);
|
||
if (startIndex > 0) {
|
||
completedEpubBoundarySegmentRef.current = null;
|
||
}
|
||
} else {
|
||
startIndex = 0;
|
||
}
|
||
setCurrentIndex(startIndex);
|
||
}
|
||
|
||
sentenceAlignmentCacheRef.current.clear();
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
|
||
if (
|
||
!isEPUB
|
||
&& normalizedOptions.nextLocation !== undefined
|
||
&& effectiveCurrentUnits.length === 1
|
||
) {
|
||
const spanningIndex = currentSegments.findIndex((segment) =>
|
||
segment.spansSourceBoundary
|
||
&& segment.startAnchor.sourceKey === currentSourceKey
|
||
&& segment.endAnchor.sourceKey !== currentSourceKey
|
||
);
|
||
const spanningSegment = spanningIndex >= 0 ? currentSegments[spanningIndex] : null;
|
||
const currentTextLength = preprocessSentenceForAudio(text).length;
|
||
const totalLength = spanningSegment ? preprocessSentenceForAudio(spanningSegment.text).length : 0;
|
||
const baseLength = spanningSegment
|
||
? Math.max(0, currentTextLength - spanningSegment.startAnchor.offset)
|
||
: 0;
|
||
const fraction = totalLength > 0 ? baseLength / totalLength : 0;
|
||
pageTurnEstimateRef.current = spanningSegment && fraction > 0 && fraction < 1
|
||
? {
|
||
location: normalizedOptions.nextLocation,
|
||
sentenceIndex: spanningIndex,
|
||
fraction,
|
||
}
|
||
: null;
|
||
} else {
|
||
pageTurnEstimateRef.current = null;
|
||
}
|
||
|
||
setIsProcessing(false);
|
||
|
||
if (shouldResumePlayback && startIndex >= newSentences.length) {
|
||
setIsPlaying(false);
|
||
return;
|
||
}
|
||
|
||
if (shouldResumePlayback && pauseEpochRef.current === pauseEpochAtStart) {
|
||
setIsPlaying(true);
|
||
}
|
||
} catch (error) {
|
||
console.warn('Error processing text:', error);
|
||
setIsProcessing(false);
|
||
toast.error('Failed to process text', {
|
||
duration: 3000,
|
||
});
|
||
}
|
||
}, [
|
||
isPlaying,
|
||
handleBlankSection,
|
||
abortAudio,
|
||
isEPUB,
|
||
activeReaderType,
|
||
invalidatePlaybackRun,
|
||
currDocPage,
|
||
documentId,
|
||
ttsSegmentMaxBlockLength,
|
||
resolvedLanguage,
|
||
clearPendingEpubJump,
|
||
]);
|
||
|
||
/**
|
||
* Toggles the playback state between playing and paused
|
||
*/
|
||
const togglePlay = useCallback(() => {
|
||
if (isPlaying) {
|
||
recordManualPause();
|
||
clearPendingEpubJump();
|
||
abortPendingTtsRequests(true);
|
||
pauseActiveHowl();
|
||
setIsPlaying(false);
|
||
return;
|
||
}
|
||
|
||
// Ensure audio is unlocked while we're still in the click/tap handler.
|
||
unlockPlaybackOnUserGesture();
|
||
|
||
const hasPreparedSentence = Boolean(playbackSegments[currentIndex]?.text ?? sentences[currentIndex]);
|
||
if (!activeHowl && !hasPreparedSentence) {
|
||
pendingPlaybackStartRef.current = true;
|
||
setIsProcessing(true);
|
||
return;
|
||
}
|
||
|
||
// Resume current sentence if we already have a paused Howl.
|
||
if (activeHowl) {
|
||
applyPlaybackRateToHowl(activeHowl);
|
||
playbackInFlightRef.current = true;
|
||
try {
|
||
activeHowl.play();
|
||
setIsPlaying(true);
|
||
return;
|
||
} catch (error) {
|
||
console.warn('Error resuming audio:', error);
|
||
playbackInFlightRef.current = false;
|
||
setActiveHowl(null);
|
||
}
|
||
}
|
||
|
||
setIsPlaying(true);
|
||
}, [
|
||
activeHowl,
|
||
currentIndex,
|
||
applyPlaybackRateToHowl,
|
||
isPlaying,
|
||
pauseActiveHowl,
|
||
playbackSegments,
|
||
recordManualPause,
|
||
clearPendingEpubJump,
|
||
abortPendingTtsRequests,
|
||
sentences,
|
||
unlockPlaybackOnUserGesture,
|
||
]);
|
||
|
||
|
||
/**
|
||
* Moves forward one sentence in the text
|
||
*/
|
||
const skipForward = useCallback(async () => {
|
||
// Only show processing state if we're currently playing
|
||
if (isPlaying) {
|
||
setIsProcessing(true);
|
||
}
|
||
invalidatePlaybackRun();
|
||
abortAudio(true);
|
||
await advance();
|
||
}, [isPlaying, abortAudio, advance, invalidatePlaybackRun]);
|
||
|
||
/**
|
||
* Moves backward one sentence in the text
|
||
*/
|
||
const skipBackward = useCallback(async () => {
|
||
// Only show processing state if we're currently playing
|
||
if (isPlaying) {
|
||
setIsProcessing(true);
|
||
}
|
||
invalidatePlaybackRun();
|
||
abortAudio(true);
|
||
await advance(true);
|
||
}, [isPlaying, abortAudio, advance, invalidatePlaybackRun]);
|
||
|
||
/**
|
||
* Updates the voice and speed settings from the configuration
|
||
*/
|
||
const updateVoiceAndSpeed = useCallback(() => {
|
||
setVoice(configVoice);
|
||
setSpeed(voiceSpeed);
|
||
setAudioSpeed(audioPlayerSpeed);
|
||
}, [configVoice, voiceSpeed, audioPlayerSpeed]);
|
||
|
||
/**
|
||
* Initializes configuration and fetches available voices
|
||
*/
|
||
useEffect(() => {
|
||
if (!configIsLoading) {
|
||
fetchVoices();
|
||
updateVoiceAndSpeed();
|
||
setTTSModel(configTTSModel);
|
||
setTTSInstructions(configTTSInstructions);
|
||
}
|
||
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
|
||
|
||
const preloadGenerationSignatureRef = useRef<string>('');
|
||
useEffect(() => {
|
||
const signature = [
|
||
documentId,
|
||
configProviderRef,
|
||
ttsModel,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||
resolvedLanguage,
|
||
ttsSegmentMaxBlockLength,
|
||
].join('|');
|
||
|
||
if (!preloadGenerationSignatureRef.current) {
|
||
preloadGenerationSignatureRef.current = signature;
|
||
return;
|
||
}
|
||
if (preloadGenerationSignatureRef.current === signature) return;
|
||
|
||
preloadGenerationSignatureRef.current = signature;
|
||
clearPendingEpubJump();
|
||
bumpEpubPreloadGeneration();
|
||
// maxBlockLength/language changes re-shape the canonical plan (new ordinals),
|
||
// so the previous handoff anchor is no longer comparable.
|
||
lastPlayedCanonicalRef.current = null;
|
||
}, [
|
||
documentId,
|
||
configProviderRef,
|
||
ttsModel,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
providerModelPolicy.supportsInstructions,
|
||
ttsInstructions,
|
||
resolvedLanguage,
|
||
ttsSegmentMaxBlockLength,
|
||
clearPendingEpubJump,
|
||
bumpEpubPreloadGeneration,
|
||
]);
|
||
|
||
/**
|
||
* Validates that the current voice is in the available voices list
|
||
* If voice is empty or invalid, use the first available voice (only in local state, don't save)
|
||
*/
|
||
useEffect(() => {
|
||
if (availableVoices.length > 0) {
|
||
// Allow Kokoro multi-voice strings (e.g., "voice1(0.5)+voice2(0.5)") for any provider
|
||
const isKokoro = configModelPolicy.isKokoroModel;
|
||
const fallbackVoice = configVoice || availableVoices[0];
|
||
const providerUnresolved = !configModelPolicy.isResolvedProviderType;
|
||
|
||
if (isKokoro && providerUnresolved && voice.includes('+')) {
|
||
const firstVoice = voice.split('+')[0]?.replace(/\([^)]*\)/g, '').trim();
|
||
if (firstVoice) {
|
||
setVoice(firstVoice);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isKokoro) {
|
||
// If Kokoro and we have any voice string (including plus/weights), don't override it.
|
||
// Only default when local voice is empty.
|
||
if (!voice) {
|
||
setVoice(fallbackVoice);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// For non-Kokoro, only force a fallback when there is no active local voice.
|
||
// If a persisted config voice exists, keep it rather than overriding from a
|
||
// potentially stale in-flight voices response during reload.
|
||
if (!voice) {
|
||
setVoice(fallbackVoice);
|
||
return;
|
||
}
|
||
|
||
if (!configVoice && !availableVoices.includes(voice)) {
|
||
setVoice(availableVoices[0]);
|
||
// Don't save to config - just use it temporarily until user explicitly selects one
|
||
}
|
||
}
|
||
}, [availableVoices, voice, configVoice, configModelPolicy]);
|
||
|
||
const getSegmentPlaybackSource = useCallback(async (
|
||
sentence: string,
|
||
sentenceIndex: number,
|
||
preload = false,
|
||
locatorOverride?: TTSSegmentLocator,
|
||
segmentKey?: string | null,
|
||
): Promise<TTSSegmentPlaybackSource | undefined> => {
|
||
const alignmentEnabledForCurrentDoc =
|
||
wordHighlightFeatureEnabled &&
|
||
((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
|
||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled));
|
||
const locator = locatorOverride || locatorForLocation(
|
||
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
|
||
currentReaderType,
|
||
);
|
||
const audioCacheKey = buildScopedSegmentCacheKey(
|
||
locator,
|
||
sentenceIndex,
|
||
sentence,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configProviderRef,
|
||
ttsModel,
|
||
configProviderType,
|
||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||
resolvedLanguage,
|
||
segmentKey,
|
||
);
|
||
|
||
const cachedManifest = segmentManifestCacheRef.current.get(audioCacheKey);
|
||
if (cachedManifest?.status === 'completed' && cachedManifest.audioPresignUrl && cachedManifest.audioFallbackUrl) {
|
||
if (alignmentEnabledForCurrentDoc && cachedManifest.alignment) {
|
||
sentenceAlignmentCacheRef.current.set(audioCacheKey, cachedManifest.alignment);
|
||
}
|
||
return {
|
||
presignUrl: cachedManifest.audioPresignUrl,
|
||
fallbackUrl: cachedManifest.audioFallbackUrl,
|
||
manifest: cachedManifest,
|
||
};
|
||
}
|
||
|
||
if (!documentId) {
|
||
if (!preload) setIsPlaying(false);
|
||
return undefined;
|
||
}
|
||
|
||
if (isAtLimit) {
|
||
if (!preload) setIsPlaying(false);
|
||
return undefined;
|
||
}
|
||
|
||
if (preload) {
|
||
const retryAtMs = segmentRetryCooldownRef.current.get(audioCacheKey);
|
||
if (shouldDeferSegmentRetry(Date.now(), retryAtMs)) {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
activeAbortControllers.current.add(controller);
|
||
|
||
const reqHeaders: TTSRequestHeaders = {
|
||
'Content-Type': 'application/json',
|
||
'x-openai-key': openApiKey || '',
|
||
'x-tts-provider': configProviderRef,
|
||
};
|
||
if (openApiBaseUrl) {
|
||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||
}
|
||
|
||
const retryOptions: TTSRetryOptions = {
|
||
maxRetries: 2,
|
||
initialDelay: 300,
|
||
maxDelay: 300,
|
||
};
|
||
|
||
try {
|
||
onTTSStart();
|
||
const maxStatusRetries = preload ? 1 : 3;
|
||
let statusAttempt = 0;
|
||
|
||
while (true) {
|
||
const persistResult = await resolveSegmentsForPersist([
|
||
{
|
||
segmentIndex: sentenceIndex,
|
||
...(segmentKey ? { segmentKey } : {}),
|
||
text: sentence,
|
||
locator,
|
||
},
|
||
]);
|
||
const persistSegments = persistResult.segments;
|
||
if (persistSegments.length === 0) {
|
||
if (!preload) setIsPlaying(false);
|
||
return undefined;
|
||
}
|
||
|
||
const ensured = await withRetry(
|
||
async () => ensureTtsSegments({
|
||
documentId,
|
||
settings: {
|
||
providerRef: configProviderRef,
|
||
providerType: configProviderType,
|
||
ttsModel,
|
||
voice,
|
||
nativeSpeed: effectiveNativeSpeed,
|
||
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||
language: resolvedLanguage,
|
||
},
|
||
segments: persistSegments,
|
||
}, reqHeaders, controller.signal),
|
||
retryOptions,
|
||
);
|
||
|
||
const segment = ensured.segments[0];
|
||
if (segment?.status === 'completed' && segment.audioPresignUrl && segment.audioFallbackUrl) {
|
||
segmentRetryCooldownRef.current.delete(audioCacheKey);
|
||
setSegmentManifestCache(audioCacheKey, segment);
|
||
if (alignmentEnabledForCurrentDoc && segment.alignment) {
|
||
sentenceAlignmentCacheRef.current.set(audioCacheKey, segment.alignment);
|
||
}
|
||
return {
|
||
presignUrl: segment.audioPresignUrl,
|
||
fallbackUrl: segment.audioFallbackUrl,
|
||
manifest: segment,
|
||
};
|
||
}
|
||
|
||
const status = segment?.status ?? 'missing';
|
||
const retryAfterSeconds =
|
||
typeof segment?.error?.retryAfterSeconds === 'number'
|
||
? segment.error.retryAfterSeconds
|
||
: undefined;
|
||
const delayMs = resolveSegmentStatusRetryDelayMs({
|
||
attempt: statusAttempt,
|
||
retryAfterSeconds,
|
||
});
|
||
const canRetryStatus = isRetryableSegmentStatus(status) && statusAttempt < maxStatusRetries;
|
||
|
||
if (preload) {
|
||
if (isRetryableSegmentStatus(status)) {
|
||
setSegmentRetryCooldown(audioCacheKey, delayMs);
|
||
}
|
||
if (canRetryStatus) {
|
||
statusAttempt += 1;
|
||
await waitForRetryDelayWithAbort(delayMs, controller.signal);
|
||
continue;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
if (canRetryStatus) {
|
||
statusAttempt += 1;
|
||
await waitForRetryDelayWithAbort(delayMs, controller.signal);
|
||
continue;
|
||
}
|
||
|
||
const detail = (() => {
|
||
if (typeof segment?.error?.detail === 'string' && segment.error.detail.trim()) {
|
||
return ` (${segment.error.detail})`;
|
||
}
|
||
return '';
|
||
})();
|
||
throw new Error(`Failed to prepare segment audio: ${status}${detail}`);
|
||
}
|
||
} catch (error) {
|
||
if (isAbortLikeError(error)) {
|
||
return undefined;
|
||
}
|
||
const status = (() => {
|
||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||
const maybe = (error as { status?: unknown }).status;
|
||
return typeof maybe === 'number' ? maybe : undefined;
|
||
}
|
||
return undefined;
|
||
})();
|
||
const code = (() => {
|
||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||
const maybe = (error as { code?: unknown }).code;
|
||
return typeof maybe === 'string' ? maybe : undefined;
|
||
}
|
||
return undefined;
|
||
})();
|
||
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
|
||
if (!preload) {
|
||
toast.error('Daily TTS limit reached.', {
|
||
id: 'tts-limit-error',
|
||
duration: 5000,
|
||
});
|
||
}
|
||
triggerRateLimit();
|
||
refreshRateLimit().catch(console.error);
|
||
return undefined;
|
||
}
|
||
if (!preload) {
|
||
setIsPlaying(false);
|
||
toast.error('TTS failed. Skipped sentence and paused.', {
|
||
id: 'tts-api-error',
|
||
duration: 7000,
|
||
});
|
||
}
|
||
throw error;
|
||
} finally {
|
||
activeAbortControllers.current.delete(controller);
|
||
onTTSComplete();
|
||
}
|
||
}, [
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
ttsModel,
|
||
ttsInstructions,
|
||
resolvedLanguage,
|
||
openApiKey,
|
||
openApiBaseUrl,
|
||
configProviderRef,
|
||
configProviderType,
|
||
providerModelPolicy.supportsInstructions,
|
||
isEPUB,
|
||
pdfHighlightEnabled,
|
||
pdfWordHighlightEnabled,
|
||
epubHighlightEnabled,
|
||
epubWordHighlightEnabled,
|
||
onTTSComplete,
|
||
onTTSStart,
|
||
isAtLimit,
|
||
documentId,
|
||
refreshRateLimit,
|
||
triggerRateLimit,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
currentReaderType,
|
||
setSegmentManifestCache,
|
||
setSegmentRetryCooldown,
|
||
waitForRetryDelayWithAbort,
|
||
isAbortLikeError,
|
||
resolveSegmentsForPersist,
|
||
]);
|
||
|
||
/**
|
||
* Processes and plays the current sentence
|
||
*
|
||
* @param {string} sentence - The sentence to process
|
||
* @param {boolean} [preload=false] - Whether this is a preload request
|
||
* @returns {Promise<TTSSegmentPlaybackSource | null>} Prepared playback source metadata
|
||
*/
|
||
const processSentence = useCallback(async (
|
||
sentence: string,
|
||
sentenceIndex: number,
|
||
preload = false,
|
||
locatorOverride?: TTSSegmentLocator,
|
||
segmentKey?: string | null,
|
||
): Promise<TTSSegmentPlaybackSource | null> => {
|
||
if (!audioContext) throw new Error('Audio context not initialized');
|
||
const locator = locatorOverride || locatorForLocation(
|
||
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
|
||
currentReaderType,
|
||
);
|
||
const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, segmentKey);
|
||
|
||
// Check if there's a pending preload request for this sentence
|
||
const pendingRequest = preloadRequests.current.get(requestKey);
|
||
if (pendingRequest) {
|
||
if (preload) {
|
||
return pendingRequest;
|
||
}
|
||
// Foreground playback must not block on batch preload promises, which can
|
||
// include many other candidates and stall autoplay progression.
|
||
preloadRequests.current.delete(requestKey);
|
||
}
|
||
|
||
// Only set processing state if not preloading
|
||
if (!preload) setIsProcessing(true);
|
||
|
||
// Create the audio processing promise
|
||
const processPromise = (async () => {
|
||
try {
|
||
const source = await getSegmentPlaybackSource(sentence, sentenceIndex, preload, locatorOverride, segmentKey);
|
||
if (preload && source) {
|
||
warmSegmentAudioUrl(requestKey, source.presignUrl, source.fallbackUrl);
|
||
}
|
||
return source || null;
|
||
} catch (error) {
|
||
setIsProcessing(false);
|
||
throw error;
|
||
}
|
||
})();
|
||
|
||
// If this is a preload request, store it in the map
|
||
if (preload) {
|
||
preloadRequests.current.set(requestKey, processPromise);
|
||
// Clean up the map entry once the promise resolves or rejects
|
||
void processPromise
|
||
.finally(() => {
|
||
preloadRequests.current.delete(requestKey);
|
||
})
|
||
.catch(() => {
|
||
// Prevent unhandled rejections from the cleanup-only chained promise.
|
||
});
|
||
}
|
||
|
||
return processPromise;
|
||
}, [audioContext, getSegmentPlaybackSource, currDocPage, currDocPageNumber, currentReaderType, isEPUB, warmSegmentAudioUrl]);
|
||
|
||
/**
|
||
* Plays the current sentence with Howl
|
||
*
|
||
* @param {string} sentence - The sentence to play
|
||
*/
|
||
const playSentenceWithHowl = useCallback(async (
|
||
sentence: string,
|
||
sentenceIndex: number,
|
||
runId: number,
|
||
segmentKey?: string | null,
|
||
playbackSegment?: CanonicalTtsSegment | null,
|
||
) => {
|
||
if (!sentence) {
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
return;
|
||
}
|
||
|
||
const MAX_RETRIES = 3;
|
||
const INITIAL_RETRY_DELAY = 1000; // 1 second
|
||
|
||
const createHowl = async (
|
||
retryCount = 0,
|
||
useFallbackSource = false,
|
||
): Promise<Howl | null> => {
|
||
if (runId !== playbackRunIdRef.current) return null;
|
||
let playErrorAttempts = 0;
|
||
const playbackSource = await processSentence(sentence, sentenceIndex, false, undefined, segmentKey);
|
||
if (runId !== playbackRunIdRef.current) return null;
|
||
if (!playbackSource) {
|
||
// Graceful exit for rate limit / abort / intentionally skipped sentence
|
||
return null;
|
||
}
|
||
// Ensure word highlighting is set even when alignment arrives from a fresh ensure
|
||
// during this playback attempt (before cache-based pre-read can see it).
|
||
if (playbackSource.manifest.alignment) {
|
||
setCurrentSentenceAlignment(playbackSource.manifest.alignment);
|
||
setCurrentWordIndex(null);
|
||
}
|
||
const audioUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl;
|
||
|
||
// Force unload any previous Howl instance to free up resources
|
||
if (activeHowl) {
|
||
activeHowl.unload();
|
||
}
|
||
|
||
// Guard against Firefox firing onend/onstop multiple times for the same Audio element.
|
||
let howlFinished = false;
|
||
|
||
return new Howl({
|
||
src: [audioUrl],
|
||
format: ['mp3', 'mpeg'],
|
||
html5: true,
|
||
preload: true,
|
||
// We never need overlapping playback for a single sentence. Keeping this low avoids
|
||
// Safari/HTML5 Audio pool exhaustion when retries happen.
|
||
pool: 1,
|
||
rate: audioSpeed,
|
||
onload: function (this: Howl) {
|
||
if (runId !== playbackRunIdRef.current) {
|
||
try { this.unload(); } catch {}
|
||
return;
|
||
}
|
||
applyPlaybackRateToHowl(this);
|
||
const estimate = pageTurnEstimateRef.current;
|
||
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
|
||
if (!visualPageChangeHandlerRef.current) return;
|
||
|
||
const duration = this.duration();
|
||
if (!duration || !Number.isFinite(duration)) return;
|
||
|
||
const delayMs = duration * estimate.fraction * 1000;
|
||
if (delayMs <= 0 || delayMs >= duration * 1000) return;
|
||
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
}
|
||
|
||
pageTurnTimeoutRef.current = setTimeout(() => {
|
||
if (!isPlaying) return;
|
||
const currentEstimate = pageTurnEstimateRef.current;
|
||
if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return;
|
||
visualPageChangeHandlerRef.current?.(currentEstimate.location);
|
||
}, delayMs);
|
||
},
|
||
onplay: function (this: Howl) {
|
||
if (runId !== playbackRunIdRef.current) {
|
||
try { this.unload(); } catch {}
|
||
return;
|
||
}
|
||
setIsProcessing(false);
|
||
startRateWatchdog(this);
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.playbackState = 'playing';
|
||
}
|
||
},
|
||
onpause: function () {
|
||
if (runId !== playbackRunIdRef.current) return;
|
||
clearRateWatchdog();
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
pageTurnTimeoutRef.current = null;
|
||
}
|
||
setIsPlaying(false);
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.playbackState = 'paused';
|
||
}
|
||
},
|
||
onplayerror: function (this: Howl, soundId, error) {
|
||
if (runId !== playbackRunIdRef.current) {
|
||
try { this.unload(); } catch {}
|
||
return;
|
||
}
|
||
const actualError = error ?? soundId;
|
||
console.warn('Howl playback error:', actualError);
|
||
|
||
// Common on iOS/Safari when the actual play() call happens after awaiting TTS.
|
||
// Do not skip/advance in this case; just pause and tell the user to tap play again.
|
||
if (isAutoplayBlockedError(actualError)) {
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
try {
|
||
this.unload();
|
||
} catch {
|
||
// ignore unload errors
|
||
}
|
||
setIsPlaying(false);
|
||
|
||
toast.error('Playback was blocked by your browser. Tap play again to start.', {
|
||
id: 'tts-playback-blocked',
|
||
duration: 4000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
playErrorAttempts += 1;
|
||
|
||
// Avoid looping for many seconds on Safari: if playback still fails after a single
|
||
// recovery attempt, skip the sentence and pause.
|
||
if (playErrorAttempts > 1) {
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
this.unload();
|
||
setIsPlaying(false);
|
||
|
||
toast.error('Audio playback failed. Skipped sentence and paused.', {
|
||
id: 'tts-playback-error',
|
||
duration: 4000,
|
||
});
|
||
|
||
advance();
|
||
return;
|
||
}
|
||
|
||
// Try to recover by reloading once.
|
||
if (this.state() === 'loaded') {
|
||
this.unload();
|
||
this.once('load', () => this.play());
|
||
this.load();
|
||
}
|
||
},
|
||
onloaderror: async function (this: Howl, soundId, error) {
|
||
if (runId !== playbackRunIdRef.current) {
|
||
try { this.unload(); } catch {}
|
||
return;
|
||
}
|
||
const actualError = error ?? soundId;
|
||
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError);
|
||
|
||
// First load failure on presigned URL should fail over to proxy fallback immediately.
|
||
if (!useFallbackSource && playbackSource.fallbackUrl && playbackSource.fallbackUrl !== playbackSource.presignUrl) {
|
||
try {
|
||
this.unload();
|
||
} catch {
|
||
// ignore unload errors
|
||
}
|
||
|
||
try {
|
||
const fallbackHowl = await createHowl(retryCount, true);
|
||
if (fallbackHowl) {
|
||
if (runId !== playbackRunIdRef.current) {
|
||
try { fallbackHowl.unload(); } catch {}
|
||
return;
|
||
}
|
||
setActiveHowl(fallbackHowl);
|
||
fallbackHowl.play();
|
||
return;
|
||
}
|
||
} catch (fallbackError) {
|
||
console.error('Error switching to fallback segment source:', fallbackError);
|
||
}
|
||
}
|
||
|
||
if (retryCount < MAX_RETRIES) {
|
||
// Calculate exponential backoff delay
|
||
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
|
||
|
||
// Free the current Howl/audio objects before retrying to avoid pool exhaustion.
|
||
try {
|
||
this.unload();
|
||
} catch {
|
||
// ignore unload errors
|
||
}
|
||
|
||
// Wait for the delay
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
|
||
// Try to create a new Howl instance
|
||
try {
|
||
const retryHowl = await createHowl(retryCount + 1, useFallbackSource);
|
||
if (retryHowl) {
|
||
if (runId !== playbackRunIdRef.current) {
|
||
try { retryHowl.unload(); } catch {}
|
||
return;
|
||
}
|
||
setActiveHowl(retryHowl);
|
||
retryHowl.play();
|
||
} else {
|
||
// No audio generated (quota/abort). Stop cleanly without spamming errors.
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
setIsPlaying(false);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error creating Howl instance:', err);
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
setIsPlaying(false);
|
||
|
||
toast.error('Audio loading failed after retries. Moving to next sentence...', {
|
||
id: 'audio-load-error',
|
||
duration: 2000,
|
||
});
|
||
|
||
advance();
|
||
}
|
||
} else {
|
||
console.error('Max retries reached, moving to next sentence');
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
this.unload();
|
||
setIsPlaying(false);
|
||
|
||
toast.error('Audio loading failed after retries. Moving to next sentence...', {
|
||
id: 'audio-load-error',
|
||
duration: 2000,
|
||
});
|
||
|
||
advance();
|
||
}
|
||
},
|
||
onend: function (this: Howl) {
|
||
if (runId !== playbackRunIdRef.current) {
|
||
try { this.unload(); } catch {}
|
||
return;
|
||
}
|
||
if (howlFinished) return; // Deduplicate – Firefox can fire ended twice
|
||
howlFinished = true;
|
||
clearRateWatchdog();
|
||
this.unload();
|
||
playbackInFlightRef.current = false;
|
||
setActiveHowl(null);
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
pageTurnTimeoutRef.current = null;
|
||
}
|
||
if (isEPUB) {
|
||
completedEpubBoundarySegmentRef.current = completedEpubBoundarySegment(playbackSegment);
|
||
// Track the highest canonical ordinal spoken in this spine item so
|
||
// the next page's window can hand off at exactly ordinal + 1.
|
||
const ownerLocator = playbackSegment?.ownerLocator;
|
||
if (
|
||
ownerLocator
|
||
&& typeof ownerLocator.spineHref === 'string'
|
||
&& typeof ownerLocator.spineIndex === 'number'
|
||
&& typeof playbackSegment?.ordinal === 'number'
|
||
) {
|
||
const prev = lastPlayedCanonicalRef.current;
|
||
const sameSpine = prev
|
||
&& prev.spineHref === ownerLocator.spineHref
|
||
&& prev.spineIndex === ownerLocator.spineIndex;
|
||
lastPlayedCanonicalRef.current = {
|
||
spineHref: ownerLocator.spineHref,
|
||
spineIndex: ownerLocator.spineIndex,
|
||
ordinal: sameSpine
|
||
? Math.max(prev!.ordinal, playbackSegment.ordinal)
|
||
: playbackSegment.ordinal,
|
||
};
|
||
}
|
||
}
|
||
if (isPlaying) {
|
||
advance();
|
||
}
|
||
},
|
||
onstop: function (this: Howl) {
|
||
if (runId !== playbackRunIdRef.current) {
|
||
try { this.unload(); } catch {}
|
||
return;
|
||
}
|
||
if (howlFinished) return;
|
||
howlFinished = true;
|
||
clearRateWatchdog();
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
this.unload();
|
||
}
|
||
});
|
||
};
|
||
|
||
try {
|
||
const howl = await createHowl();
|
||
if (runId !== playbackRunIdRef.current) {
|
||
if (howl) {
|
||
try { howl.unload(); } catch {}
|
||
}
|
||
return null;
|
||
}
|
||
if (!howl) {
|
||
// No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without
|
||
// advancing or spamming errors.
|
||
playbackInFlightRef.current = false;
|
||
setActiveHowl(null);
|
||
setIsProcessing(false);
|
||
setIsPlaying(false);
|
||
return null;
|
||
}
|
||
|
||
setActiveHowl(howl);
|
||
return howl;
|
||
} catch (error) {
|
||
console.error('Error playing TTS:', error);
|
||
playbackInFlightRef.current = false;
|
||
setActiveHowl(null);
|
||
setIsProcessing(false);
|
||
|
||
// Skip the sentence but pause playback (user can resume manually).
|
||
abortAudio(true);
|
||
setIsPlaying(false);
|
||
advance();
|
||
return null;
|
||
}
|
||
}, [
|
||
abortAudio,
|
||
isPlaying,
|
||
advance,
|
||
activeHowl,
|
||
processSentence,
|
||
audioSpeed,
|
||
isEPUB,
|
||
isAutoplayBlockedError,
|
||
applyPlaybackRateToHowl,
|
||
startRateWatchdog,
|
||
clearRateWatchdog,
|
||
]);
|
||
|
||
const playAudio = useCallback(async () => {
|
||
const runId = playbackRunIdRef.current;
|
||
const playbackSegment = playbackSegments[currentIndex];
|
||
const sentence = playbackSegment?.text ?? sentences[currentIndex];
|
||
if (isEPUB && playbackSegment && completedEpubBoundarySegmentRef.current) {
|
||
const suppression = resolveEpubReplaySuppressionAction(
|
||
playbackSegments,
|
||
currentIndex,
|
||
completedEpubBoundarySegmentRef.current,
|
||
);
|
||
if (suppression.kind === 'skip-to-index') {
|
||
playbackInFlightRef.current = false;
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
completedEpubBoundarySegmentRef.current = null;
|
||
setCurrentIndex(suppression.index);
|
||
return;
|
||
}
|
||
if (suppression.kind === 'pause') {
|
||
playbackInFlightRef.current = false;
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
completedEpubBoundarySegmentRef.current = null;
|
||
setIsProcessing(false);
|
||
setIsPlaying(false);
|
||
return;
|
||
}
|
||
completedEpubBoundarySegmentRef.current = null;
|
||
}
|
||
const activeLocator: TTSSegmentLocator = playbackSegment?.ownerLocator
|
||
?? locatorForLocation(
|
||
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
|
||
currentReaderType,
|
||
);
|
||
const alignmentKey = buildScopedSegmentCacheKey(
|
||
activeLocator,
|
||
currentIndex,
|
||
sentence,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configProviderRef,
|
||
ttsModel,
|
||
configProviderType,
|
||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||
resolvedLanguage,
|
||
playbackSegment?.key,
|
||
);
|
||
const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey);
|
||
if (cachedAlignment) {
|
||
setCurrentSentenceAlignment(cachedAlignment);
|
||
setCurrentWordIndex(null);
|
||
} else {
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
}
|
||
|
||
const howl = await playSentenceWithHowl(sentence, currentIndex, runId, playbackSegment?.key, playbackSegment);
|
||
if (runId !== playbackRunIdRef.current) {
|
||
if (howl) {
|
||
try { howl.unload(); } catch {}
|
||
}
|
||
return;
|
||
}
|
||
if (howl) {
|
||
if (!isPlayingRef.current) {
|
||
playbackInFlightRef.current = false;
|
||
return;
|
||
}
|
||
howl.play();
|
||
}
|
||
}, [
|
||
sentences,
|
||
playbackSegments,
|
||
currentIndex,
|
||
playSentenceWithHowl,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configProviderRef,
|
||
ttsModel,
|
||
configProviderType,
|
||
providerModelPolicy.supportsInstructions,
|
||
ttsInstructions,
|
||
resolvedLanguage,
|
||
isEPUB,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
currentReaderType,
|
||
]);
|
||
|
||
// Keep the current playback rate applied to the active Howl. Some browsers (notably
|
||
// iOS Safari with HTML5 audio) can reset playbackRate after initial load/play.
|
||
useEffect(() => {
|
||
if (!activeHowl) return;
|
||
applyPlaybackRateToHowl(activeHowl);
|
||
if (isPlaying) {
|
||
startRateWatchdog(activeHowl);
|
||
}
|
||
}, [activeHowl, audioSpeed, applyPlaybackRateToHowl, isPlaying, startRateWatchdog]);
|
||
|
||
// Track the current word index during playback using Howler's seek position
|
||
useEffect(() => {
|
||
if (!activeHowl || !isPlaying || !currentSentenceAlignment || !currentSentenceAlignment.words.length) {
|
||
setCurrentWordIndex(null);
|
||
return;
|
||
}
|
||
|
||
let frameId: number;
|
||
|
||
const tick = () => {
|
||
try {
|
||
const pos = activeHowl.seek() as number;
|
||
if (typeof pos === 'number' && Number.isFinite(pos)) {
|
||
const words = currentSentenceAlignment.words;
|
||
let idx = -1;
|
||
for (let i = 0; i < words.length; i++) {
|
||
const w = words[i];
|
||
if (pos >= w.startSec && pos < w.endSec) {
|
||
idx = i;
|
||
break;
|
||
}
|
||
}
|
||
if (idx !== -1) {
|
||
setCurrentWordIndex((prev) => (prev === idx ? prev : idx));
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore seek errors
|
||
}
|
||
frameId = requestAnimationFrame(tick);
|
||
};
|
||
|
||
frameId = requestAnimationFrame(tick);
|
||
|
||
return () => {
|
||
if (frameId) {
|
||
cancelAnimationFrame(frameId);
|
||
}
|
||
};
|
||
}, [activeHowl, isPlaying, currentSentenceAlignment]);
|
||
|
||
/**
|
||
* Preloads upcoming sentences in batched ensure requests.
|
||
* Includes the current location and prefetched future locations when available.
|
||
*/
|
||
const preloadNextAudio = useCallback(() => {
|
||
if (isAtLimit || !documentId) return;
|
||
const sentenceLookahead = clampSegmentPreloadSentenceLookahead(segmentPreloadSentenceLookahead);
|
||
|
||
const maxDepth = clampSegmentPreloadDepth(segmentPreloadDepthPages);
|
||
|
||
if (isEPUB) {
|
||
const generationAtStart = epubPreloadGenerationRef.current;
|
||
const settingsHash = [
|
||
configProviderRef,
|
||
ttsModel,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||
].join('|');
|
||
const walkStartKey = `${currDocPage}|${settingsHash}|${ttsSegmentMaxBlockLength}|${maxDepth}`;
|
||
|
||
const preloadFromOffset = (offset: number) => {
|
||
if (offset > sentenceLookahead) return;
|
||
const sentenceIndex = currentIndex + offset;
|
||
const nextSegment = playbackSegments[sentenceIndex];
|
||
const nextSentence = nextSegment?.text ?? sentences[sentenceIndex];
|
||
if (!nextSentence) return;
|
||
|
||
const currentLocator: TTSSegmentLocator =
|
||
nextSegment?.ownerLocator ?? { location: String(currDocPage), readerType: currentReaderType };
|
||
const requestKey = buildSegmentRequestKey(currentLocator, sentenceIndex, nextSentence, nextSegment?.key);
|
||
const cacheKey = buildScopedSegmentCacheKey(
|
||
currentLocator,
|
||
sentenceIndex,
|
||
nextSentence,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configProviderRef,
|
||
ttsModel,
|
||
configProviderType,
|
||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||
resolvedLanguage,
|
||
nextSegment?.key,
|
||
);
|
||
if (segmentManifestCacheRef.current.has(cacheKey)) {
|
||
preloadFromOffset(offset + 1);
|
||
return;
|
||
}
|
||
const cooldownRetryAtMs = segmentRetryCooldownRef.current.get(cacheKey);
|
||
if (shouldDeferSegmentRetry(Date.now(), cooldownRetryAtMs)) {
|
||
preloadFromOffset(offset + 1);
|
||
return;
|
||
}
|
||
const pending = preloadRequests.current.get(requestKey);
|
||
if (pending) {
|
||
void pending
|
||
.finally(() => preloadFromOffset(offset + 1))
|
||
.catch(() => {});
|
||
return;
|
||
}
|
||
|
||
void processSentence(nextSentence, sentenceIndex, true, currentLocator, nextSegment?.key)
|
||
.catch((error) => {
|
||
const status = typeof error === 'object' && error !== null && 'status' in error
|
||
? ((error as { status?: unknown }).status as number | undefined)
|
||
: undefined;
|
||
const code = typeof error === 'object' && error !== null && 'code' in error
|
||
? ((error as { code?: unknown }).code as string | undefined)
|
||
: undefined;
|
||
if (!isAbortLikeError(error) && !(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) {
|
||
console.error(`Error preloading EPUB sentence at offset ${offset}:`, error);
|
||
}
|
||
})
|
||
.finally(() => preloadFromOffset(offset + 1));
|
||
};
|
||
|
||
preloadFromOffset(1);
|
||
if (
|
||
maxDepth <= 1
|
||
|| !epubLocationWalkerRef.current
|
||
|| typeof currDocPage !== 'string'
|
||
|| epubWalkInFlightRef.current.has(walkStartKey)
|
||
) {
|
||
return;
|
||
}
|
||
epubWalkInFlightRef.current.add(walkStartKey);
|
||
|
||
const controller = new AbortController();
|
||
activeAbortControllers.current.add(controller);
|
||
const reqHeaders: TTSRequestHeaders = {
|
||
'Content-Type': 'application/json',
|
||
'x-openai-key': openApiKey || '',
|
||
'x-tts-provider': configProviderRef,
|
||
};
|
||
if (openApiBaseUrl) {
|
||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||
}
|
||
const retryOptions: TTSRetryOptions = {
|
||
maxRetries: 2,
|
||
initialDelay: 300,
|
||
maxDelay: 300,
|
||
};
|
||
let started = false;
|
||
const alignmentEnabledForCurrentDoc =
|
||
wordHighlightFeatureEnabled &&
|
||
epubHighlightEnabled &&
|
||
epubWordHighlightEnabled;
|
||
|
||
const requestedDepth = Math.max(1, maxDepth);
|
||
void epubLocationWalkerRef.current(currDocPage, requestedDepth, controller.signal)
|
||
.then(async (locationItems) => {
|
||
if (controller.signal.aborted) return;
|
||
if (generationAtStart !== epubPreloadGenerationRef.current) return;
|
||
if (!locationItems.length) return;
|
||
|
||
const upcomingLocationItems = selectUpcomingWalkerItems(
|
||
locationItems,
|
||
String(currDocPage),
|
||
maxDepth,
|
||
);
|
||
if (!upcomingLocationItems.length) return;
|
||
|
||
// Build a stable EPUB locator for each rendered chunk from the
|
||
// walker's spine coordinates. These are viewport-independent — the
|
||
// same content yields the same locator across devices and resizes.
|
||
const locatorForWalkerItem = (
|
||
item: typeof upcomingLocationItems[number],
|
||
): TTSSegmentLocator => ({
|
||
readerType: 'epub',
|
||
spineHref: item.spineHref,
|
||
spineIndex: item.spineIndex,
|
||
charOffset: item.chunkOffset,
|
||
cfi: item.cfi,
|
||
});
|
||
|
||
// Prefer canonical per-chunk segments attached by the walker — these
|
||
// are viewport-independent and mint identical keys/locators to what
|
||
// playback will request, so warmed audio actually hits the cache.
|
||
// Only chunks the walker couldn't canonicalize fall back to the
|
||
// legacy preview-based plan.
|
||
const fallbackItems = upcomingLocationItems.filter((item) => !item.segments?.length);
|
||
let fallbackSegments: CanonicalTtsSegment[] = [];
|
||
if (fallbackItems.length > 0) {
|
||
const upcomingUnits: CanonicalTtsSourceUnit[] = fallbackItems.map((item) => ({
|
||
sourceKey: sourceKeyForLocation(item.cfi, currDocPage),
|
||
text: item.text,
|
||
locator: locatorForWalkerItem(item),
|
||
}));
|
||
const liveContextUnits = currentSourceContextUnitsRef.current.length > 0
|
||
? currentSourceContextUnitsRef.current
|
||
: (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []);
|
||
const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits(
|
||
liveContextUnits,
|
||
upcomingUnits,
|
||
);
|
||
fallbackSegments = planCanonicalTtsSegments(sourceUnits, {
|
||
readerType: 'epub',
|
||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||
keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'),
|
||
language: resolvedLanguage,
|
||
}).segments;
|
||
}
|
||
|
||
const uniqueCandidates: Array<EpubLocationPreloadCandidate & { locator: TTSSegmentLocator }> = [];
|
||
const seenCandidates = new Set<string>();
|
||
for (const item of upcomingLocationItems) {
|
||
const sourceKey = sourceKeyForLocation(item.cfi, currDocPage);
|
||
const planned = (item.segments?.length
|
||
? item.segments
|
||
: fallbackSegments.filter((segment) => segment.ownerSourceKey === sourceKey)
|
||
).slice(0, sentenceLookahead);
|
||
for (let index = 0; index < planned.length; index += 1) {
|
||
const segment = planned[index];
|
||
const locator = segment.ownerLocator ?? locatorForWalkerItem(item);
|
||
const requestKey = buildSegmentRequestKey(locator, index, segment.text, segment.key);
|
||
const cacheKey = buildScopedSegmentCacheKey(
|
||
locator,
|
||
index,
|
||
segment.text,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configProviderRef,
|
||
ttsModel,
|
||
configProviderType,
|
||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||
resolvedLanguage,
|
||
segment.key,
|
||
);
|
||
if (seenCandidates.has(requestKey)) continue;
|
||
seenCandidates.add(requestKey);
|
||
if (segmentManifestCacheRef.current.has(cacheKey)) continue;
|
||
const cooldownRetryAtMs = segmentRetryCooldownRef.current.get(cacheKey);
|
||
if (shouldDeferSegmentRetry(Date.now(), cooldownRetryAtMs)) continue;
|
||
if (preloadRequests.current.has(requestKey)) continue;
|
||
uniqueCandidates.push({
|
||
sentence: segment.text,
|
||
segmentKey: segment.key,
|
||
segmentIndex: index,
|
||
location: item.cfi,
|
||
requestKey,
|
||
cacheKey,
|
||
locator,
|
||
});
|
||
}
|
||
}
|
||
if (uniqueCandidates.length === 0) return;
|
||
|
||
const payload = uniqueCandidates.map((candidate) => ({
|
||
segmentIndex: candidate.segmentIndex,
|
||
segmentKey: candidate.segmentKey,
|
||
text: candidate.sentence,
|
||
locator: candidate.locator,
|
||
}));
|
||
|
||
const preloadPromise = (async (): Promise<void> => {
|
||
onTTSStart();
|
||
started = true;
|
||
const persistResult = await resolveSegmentsForPersist(payload);
|
||
const persistPayload = persistResult.segments;
|
||
if (persistPayload.length === 0) return;
|
||
const ensured = await withRetry(
|
||
async () => ensureTtsSegments({
|
||
documentId,
|
||
settings: {
|
||
providerRef: configProviderRef,
|
||
providerType: configProviderType,
|
||
ttsModel,
|
||
voice,
|
||
nativeSpeed: effectiveNativeSpeed,
|
||
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||
language: resolvedLanguage,
|
||
},
|
||
segments: persistPayload,
|
||
}, reqHeaders, controller.signal),
|
||
retryOptions,
|
||
);
|
||
|
||
if (generationAtStart !== epubPreloadGenerationRef.current) return;
|
||
|
||
ensured.segments.forEach((segment, persistIndex) => {
|
||
if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) return;
|
||
const sourceIndex = persistResult.sourceIndices[persistIndex];
|
||
if (sourceIndex === undefined) return;
|
||
const candidate = uniqueCandidates[sourceIndex];
|
||
if (!candidate) return;
|
||
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
|
||
});
|
||
})();
|
||
|
||
for (const candidate of uniqueCandidates) {
|
||
const candidatePromise: Promise<TTSSegmentPlaybackSource | null> = preloadPromise.then(() => {
|
||
const manifest = segmentManifestCacheRef.current.get(candidate.cacheKey);
|
||
if (!manifest || manifest.status !== 'completed' || !manifest.audioPresignUrl || !manifest.audioFallbackUrl) {
|
||
return null;
|
||
}
|
||
warmSegmentAudioUrl(candidate.requestKey, manifest.audioPresignUrl, manifest.audioFallbackUrl);
|
||
return {
|
||
presignUrl: manifest.audioPresignUrl,
|
||
fallbackUrl: manifest.audioFallbackUrl,
|
||
manifest,
|
||
};
|
||
});
|
||
void candidatePromise.catch(() => {});
|
||
preloadRequests.current.set(candidate.requestKey, candidatePromise);
|
||
}
|
||
|
||
await preloadPromise.finally(() => {
|
||
for (const candidate of uniqueCandidates) {
|
||
preloadRequests.current.delete(candidate.requestKey);
|
||
}
|
||
});
|
||
})
|
||
.catch((error) => {
|
||
const status = typeof error === 'object' && error !== null && 'status' in error
|
||
? ((error as { status?: unknown }).status as number | undefined)
|
||
: undefined;
|
||
const code = typeof error === 'object' && error !== null && 'code' in error
|
||
? ((error as { code?: unknown }).code as string | undefined)
|
||
: undefined;
|
||
if (!isAbortLikeError(error) && !(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) {
|
||
console.error('Error preloading EPUB location segments:', error);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
epubWalkInFlightRef.current.delete(walkStartKey);
|
||
activeAbortControllers.current.delete(controller);
|
||
if (started) {
|
||
onTTSComplete();
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
const alignmentEnabledForCurrentDoc =
|
||
wordHighlightFeatureEnabled &&
|
||
((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
|
||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled));
|
||
const candidates: Array<{
|
||
sentence: string;
|
||
segmentKey?: string | null;
|
||
segmentIndex: number;
|
||
locator: TTSSegmentLocator;
|
||
requestKey: string;
|
||
cacheKey: string;
|
||
}> = [];
|
||
|
||
const currentLocator: TTSSegmentLocator = locatorForLocation(
|
||
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
|
||
currentReaderType,
|
||
);
|
||
|
||
for (let offset = 1; offset <= sentenceLookahead; offset += 1) {
|
||
const sentenceIndex = currentIndex + offset;
|
||
const plannedSegment = playbackSegments[sentenceIndex];
|
||
const sentence = plannedSegment?.text ?? sentences[sentenceIndex];
|
||
if (!sentence) break;
|
||
const locator = plannedSegment?.ownerLocator ?? currentLocator;
|
||
const cacheKey = buildScopedSegmentCacheKey(
|
||
locator,
|
||
sentenceIndex,
|
||
sentence,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configProviderRef,
|
||
ttsModel,
|
||
configProviderType,
|
||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||
resolvedLanguage,
|
||
plannedSegment?.key,
|
||
);
|
||
const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, plannedSegment?.key);
|
||
candidates.push({ sentence, segmentKey: plannedSegment?.key, segmentIndex: sentenceIndex, locator, requestKey, cacheKey });
|
||
}
|
||
|
||
const prefetched = Array.from(plannedSegmentsByLocationRef.current.entries()).slice(0, maxDepth);
|
||
for (const [locationKey, planned] of prefetched) {
|
||
if (!planned.length) continue;
|
||
const location = locationKey.startsWith('num:')
|
||
? Number(locationKey.slice(4))
|
||
: locationKey.startsWith('str:')
|
||
? locationKey.slice(4)
|
||
: null;
|
||
if (location === null) continue;
|
||
|
||
const locator: TTSSegmentLocator = locatorForLocation(location, currentReaderType);
|
||
for (let index = 0; index < Math.min(sentenceLookahead, planned.length); index += 1) {
|
||
const segment = planned[index];
|
||
const sentence = segment.text;
|
||
const segmentLocator = segment.ownerLocator ?? locator;
|
||
const cacheKey = buildScopedSegmentCacheKey(
|
||
segmentLocator,
|
||
index,
|
||
sentence,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configProviderRef,
|
||
ttsModel,
|
||
configProviderType,
|
||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||
resolvedLanguage,
|
||
segment.key,
|
||
);
|
||
const requestKey = buildSegmentRequestKey(segmentLocator, index, sentence, segment.key);
|
||
candidates.push({ sentence, segmentKey: segment.key, segmentIndex: index, locator: segmentLocator, requestKey, cacheKey });
|
||
}
|
||
}
|
||
|
||
const uniqueCandidates: typeof candidates = [];
|
||
const seen = new Set<string>();
|
||
for (const candidate of candidates) {
|
||
if (seen.has(candidate.requestKey)) continue;
|
||
seen.add(candidate.requestKey);
|
||
if (segmentManifestCacheRef.current.has(candidate.cacheKey)) continue;
|
||
const cooldownRetryAtMs = segmentRetryCooldownRef.current.get(candidate.cacheKey);
|
||
if (shouldDeferSegmentRetry(Date.now(), cooldownRetryAtMs)) continue;
|
||
if (preloadRequests.current.has(candidate.requestKey)) continue;
|
||
uniqueCandidates.push(candidate);
|
||
}
|
||
if (uniqueCandidates.length === 0) return;
|
||
|
||
const controller = new AbortController();
|
||
activeAbortControllers.current.add(controller);
|
||
const reqHeaders: TTSRequestHeaders = {
|
||
'Content-Type': 'application/json',
|
||
'x-openai-key': openApiKey || '',
|
||
'x-tts-provider': configProviderRef,
|
||
};
|
||
if (openApiBaseUrl) {
|
||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||
}
|
||
|
||
const retryOptions: TTSRetryOptions = {
|
||
maxRetries: 2,
|
||
initialDelay: 300,
|
||
maxDelay: 300,
|
||
};
|
||
const payload = uniqueCandidates.map((candidate) => ({
|
||
segmentIndex: candidate.segmentIndex,
|
||
...(candidate.segmentKey ? { segmentKey: candidate.segmentKey } : {}),
|
||
text: candidate.sentence,
|
||
locator: candidate.locator,
|
||
}));
|
||
const candidateLookup = new Map<string, typeof uniqueCandidates[number]>();
|
||
const candidateLookupKey = (segmentIndex: number, segmentKey?: string | null) =>
|
||
`${segmentIndex}|${segmentKey || ''}`;
|
||
for (const candidate of uniqueCandidates) {
|
||
candidateLookup.set(candidateLookupKey(candidate.segmentIndex, candidate.segmentKey), candidate);
|
||
}
|
||
const preloadPromise = (async (): Promise<void> => {
|
||
try {
|
||
onTTSStart();
|
||
const persistResult = await resolveSegmentsForPersist(payload);
|
||
const persistPayload = persistResult.segments;
|
||
if (persistPayload.length === 0) return;
|
||
const ensured = await withRetry(
|
||
async () => ensureTtsSegments({
|
||
documentId,
|
||
settings: {
|
||
providerRef: configProviderRef,
|
||
providerType: configProviderType,
|
||
ttsModel,
|
||
voice,
|
||
nativeSpeed: effectiveNativeSpeed,
|
||
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||
language: resolvedLanguage,
|
||
},
|
||
segments: persistPayload,
|
||
}, reqHeaders, controller.signal),
|
||
retryOptions,
|
||
);
|
||
|
||
ensured.segments.forEach((segment, persistIndex) => {
|
||
const sourceIndex = persistResult.sourceIndices[persistIndex];
|
||
if (sourceIndex === undefined) return;
|
||
const candidate = uniqueCandidates[sourceIndex]
|
||
?? candidateLookup.get(candidateLookupKey(segment.segmentIndex, segment.segmentKey));
|
||
if (!candidate) return;
|
||
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
|
||
});
|
||
} finally {
|
||
activeAbortControllers.current.delete(controller);
|
||
onTTSComplete();
|
||
}
|
||
})();
|
||
|
||
for (const candidate of uniqueCandidates) {
|
||
const candidatePromise: Promise<TTSSegmentPlaybackSource | null> = preloadPromise.then(() => {
|
||
const manifest = segmentManifestCacheRef.current.get(candidate.cacheKey);
|
||
if (!manifest || manifest.status !== 'completed' || !manifest.audioPresignUrl || !manifest.audioFallbackUrl) {
|
||
return null;
|
||
}
|
||
warmSegmentAudioUrl(candidate.requestKey, manifest.audioPresignUrl, manifest.audioFallbackUrl);
|
||
return {
|
||
presignUrl: manifest.audioPresignUrl,
|
||
fallbackUrl: manifest.audioFallbackUrl,
|
||
manifest,
|
||
};
|
||
});
|
||
// Background preload is best-effort; aborted batches should not surface as
|
||
// unhandled promise rejections in dev.
|
||
void candidatePromise.catch(() => {});
|
||
preloadRequests.current.set(candidate.requestKey, candidatePromise);
|
||
}
|
||
|
||
void preloadPromise
|
||
.catch((error) => {
|
||
const status = (() => {
|
||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||
const maybe = (error as { status?: unknown }).status;
|
||
return typeof maybe === 'number' ? maybe : undefined;
|
||
}
|
||
return undefined;
|
||
})();
|
||
const code = (() => {
|
||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||
const maybe = (error as { code?: unknown }).code;
|
||
return typeof maybe === 'string' ? maybe : undefined;
|
||
}
|
||
return undefined;
|
||
})();
|
||
if (!isAbortLikeError(error) && !(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) {
|
||
console.error('Error preloading batched segments:', error);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
for (const candidate of uniqueCandidates) {
|
||
preloadRequests.current.delete(candidate.requestKey);
|
||
}
|
||
});
|
||
}, [
|
||
isAtLimit,
|
||
documentId,
|
||
isEPUB,
|
||
segmentPreloadDepthPages,
|
||
segmentPreloadSentenceLookahead,
|
||
ttsSegmentMaxBlockLength,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
currentReaderType,
|
||
currentIndex,
|
||
sentences,
|
||
playbackSegments,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configProviderRef,
|
||
configProviderType,
|
||
ttsModel,
|
||
openApiKey,
|
||
openApiBaseUrl,
|
||
providerModelPolicy.supportsInstructions,
|
||
ttsInstructions,
|
||
resolvedLanguage,
|
||
onTTSStart,
|
||
onTTSComplete,
|
||
processSentence,
|
||
cacheCompletedManifestForCandidate,
|
||
isAbortLikeError,
|
||
pdfHighlightEnabled,
|
||
pdfWordHighlightEnabled,
|
||
epubHighlightEnabled,
|
||
epubWordHighlightEnabled,
|
||
resolveSegmentsForPersist,
|
||
warmSegmentAudioUrl,
|
||
]);
|
||
|
||
/**
|
||
* Main Playback Driver
|
||
* Controls the flow of audio playback and sentence processing
|
||
*/
|
||
useEffect(() => {
|
||
if (!isPlaying) {
|
||
playbackInFlightRef.current = false;
|
||
return;
|
||
}
|
||
if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play
|
||
// Single synchronous guard: covers the entire window from when playAudio() is
|
||
// called until the Howl finishes (onend/onstop/error). React state guards
|
||
// (isProcessing, activeHowl) are async and leave a micro-task gap that allows
|
||
// the effect to re-fire and start duplicate playback — especially in Firefox
|
||
// where HTML5 Audio events can trigger extra renders.
|
||
if (playbackInFlightRef.current) return;
|
||
playbackInFlightRef.current = true;
|
||
|
||
// Start playing current sentence
|
||
playAudio();
|
||
|
||
// Start background lookahead preloading for upcoming sentences.
|
||
preloadNextAudio();
|
||
|
||
return () => {
|
||
// Only abort if we're actually stopping playback
|
||
if (!isPlaying) {
|
||
abortAudio();
|
||
}
|
||
};
|
||
}, [
|
||
isPlaying,
|
||
currentIndex,
|
||
sentences,
|
||
playAudio,
|
||
preloadNextAudio,
|
||
abortAudio
|
||
]);
|
||
|
||
/**
|
||
* Stops the current audio playback and resets all state
|
||
*/
|
||
const stop = useCallback(() => {
|
||
// Cancel any ongoing request
|
||
invalidatePlaybackRun();
|
||
abortAudio();
|
||
clearWarmAudioCache();
|
||
playbackInFlightRef.current = false;
|
||
pendingPlaybackStartRef.current = false;
|
||
pendingJumpTargetRef.current = null;
|
||
clearPendingEpubJump();
|
||
bumpEpubPreloadGeneration();
|
||
plannedSegmentsByLocationRef.current.clear();
|
||
currentSourceUnitRef.current = null;
|
||
currentSourceContextUnitsRef.current = [];
|
||
completedEpubBoundarySegmentRef.current = null;
|
||
lastPlayedCanonicalRef.current = null;
|
||
pageFirstBlockFingerprintRef.current.clear();
|
||
setIsPlaying(false);
|
||
setCurrentIndex(0);
|
||
setSentences([]);
|
||
setPlaybackSegments([]);
|
||
setCurrDocPage(1);
|
||
setCurrDocPages(undefined);
|
||
setIsProcessing(false);
|
||
setIsEPUB(false);
|
||
sentenceAlignmentCacheRef.current.clear();
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
}, [abortAudio, clearWarmAudioCache, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]);
|
||
|
||
const clearSegmentCaches = useCallback(() => {
|
||
// Keep the current viewport/sentence list intact, but force all audio/manifest
|
||
// state to be re-resolved after a server-side clear.
|
||
abortAudio(true);
|
||
segmentManifestCacheRef.current.clear();
|
||
sentenceAlignmentCacheRef.current.clear();
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
}, [abortAudio]);
|
||
|
||
/**
|
||
* Stops the current audio playback and starts playing from a specified index
|
||
*
|
||
* @param {number} index - The index to start playing from
|
||
*/
|
||
const stopAndPlayFromIndex = useCallback((index: number) => {
|
||
invalidatePlaybackRun();
|
||
abortAudio();
|
||
|
||
// Same autoplay-unlock issue as togglePlay when starting from a fresh load.
|
||
unlockPlaybackOnUserGesture();
|
||
|
||
setCurrentIndex(index);
|
||
setIsPlaying(true);
|
||
}, [abortAudio, invalidatePlaybackRun, unlockPlaybackOnUserGesture]);
|
||
|
||
const playFromSegment = useCallback((index: number, locator?: TTSSegmentLocator | null) => {
|
||
if (isEPUB) {
|
||
clearPendingEpubJump();
|
||
}
|
||
|
||
const resolvedLocation: TTSLocation | undefined = (() => {
|
||
if (!locator) return undefined;
|
||
// Stable EPUB locators carry the jump-hint CFI in `cfi`, not `location`
|
||
// (which is now reserved for HTML / legacy rows).
|
||
if (locator.readerType === 'epub' && typeof locator.cfi === 'string' && locator.cfi) {
|
||
return locator.cfi;
|
||
}
|
||
if (typeof locator.location === 'string' && locator.location) return locator.location;
|
||
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) return Math.floor(locator.page);
|
||
return undefined;
|
||
})();
|
||
|
||
if (resolvedLocation === undefined) {
|
||
stopAndPlayFromIndex(index);
|
||
return;
|
||
}
|
||
|
||
const isSameLocation = typeof resolvedLocation === 'string'
|
||
? String(currDocPage) === String(resolvedLocation)
|
||
: Number(currDocPageNumber || 1) === Number(resolvedLocation);
|
||
|
||
if (isSameLocation) {
|
||
stopAndPlayFromIndex(index);
|
||
return;
|
||
}
|
||
|
||
invalidatePlaybackRun();
|
||
abortAudio();
|
||
unlockPlaybackOnUserGesture();
|
||
if (isEPUB) {
|
||
// CFI snapping makes locationKey unreliable; resolve via epoch on next setText.
|
||
// Carry the target's per-segment charOffset so the canonical window can
|
||
// re-anchor exactly (raw index is viewport-relative to the sidebar).
|
||
pendingEpubJumpRef.current = {
|
||
index: Math.max(0, index),
|
||
epoch: epubJumpEpochRef.current,
|
||
charOffset: typeof locator?.charOffset === 'number' ? locator.charOffset : undefined,
|
||
};
|
||
pendingJumpTargetRef.current = null;
|
||
// A jump breaks ordinal continuity — drop the handoff anchor so the new
|
||
// page plays from its resolved index rather than being trimmed.
|
||
lastPlayedCanonicalRef.current = null;
|
||
} else {
|
||
pendingJumpTargetRef.current = {
|
||
locationKey: normalizeLocationKey(resolvedLocation),
|
||
index: Math.max(0, index),
|
||
};
|
||
}
|
||
resumeAfterLocationChangeRef.current = true;
|
||
setCurrentIndex(0);
|
||
setIsPlaying(true);
|
||
if (isEPUB && locationChangeHandlerRef.current) {
|
||
locationChangeHandlerRef.current(resolvedLocation);
|
||
return;
|
||
}
|
||
skipToLocation(resolvedLocation, false);
|
||
}, [
|
||
stopAndPlayFromIndex,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
isEPUB,
|
||
invalidatePlaybackRun,
|
||
abortAudio,
|
||
unlockPlaybackOnUserGesture,
|
||
skipToLocation,
|
||
clearPendingEpubJump,
|
||
]);
|
||
|
||
/**
|
||
* Sets the speed and restarts the playback
|
||
*
|
||
* @param {number} newSpeed - The new speed to set
|
||
*/
|
||
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
||
const wasPlaying = isPlaying;
|
||
|
||
// Bump restart sequence to invalidate older restarts
|
||
const mySeq = ++restartSeqRef.current;
|
||
|
||
// Set a flag to prevent double audio requests during config update
|
||
setIsProcessing(true);
|
||
|
||
// First stop any current playback
|
||
setIsPlaying(false);
|
||
clearPendingEpubJump();
|
||
abortAudio(true); // Clear pending requests since speed changed
|
||
setActiveHowl(null);
|
||
|
||
// Update speed and config
|
||
setSpeed(newSpeed);
|
||
|
||
// Update config after state changes
|
||
updateConfigKey('voiceSpeed', newSpeed).then(() => {
|
||
setIsProcessing(false);
|
||
// Resume playback if it was playing before and this is the latest restart
|
||
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||
setIsPlaying(true);
|
||
}
|
||
});
|
||
}, [abortAudio, updateConfigKey, isPlaying, clearPendingEpubJump]);
|
||
|
||
/**
|
||
* Sets the voice and restarts the playback
|
||
*
|
||
* @param {string} newVoice - The new voice to set
|
||
*/
|
||
const setVoiceAndRestart = useCallback((newVoice: string) => {
|
||
const wasPlaying = isPlaying;
|
||
|
||
// Bump restart sequence to invalidate older restarts
|
||
const mySeq = ++restartSeqRef.current;
|
||
|
||
// Set a flag to prevent double audio requests during config update
|
||
setIsProcessing(true);
|
||
|
||
// First stop any current playback
|
||
setIsPlaying(false);
|
||
clearPendingEpubJump();
|
||
abortAudio(true); // Clear pending requests since voice changed
|
||
setActiveHowl(null);
|
||
|
||
// Update voice and config
|
||
setVoice(newVoice);
|
||
|
||
// Update config after state changes
|
||
updateConfigKey('voice', newVoice).then(() => {
|
||
setIsProcessing(false);
|
||
// Resume playback if it was playing before and this is the latest restart
|
||
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||
setIsPlaying(true);
|
||
}
|
||
});
|
||
}, [abortAudio, updateConfigKey, isPlaying, clearPendingEpubJump]);
|
||
|
||
/**
|
||
* Sets the audio player speed and restarts the playback
|
||
*
|
||
* @param {number} newSpeed - The new audio player speed to set
|
||
*/
|
||
const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => {
|
||
const wasPlaying = isPlaying;
|
||
|
||
// Bump restart sequence to invalidate older restarts
|
||
const mySeq = ++restartSeqRef.current;
|
||
|
||
// Set a flag to prevent double audio requests during config update
|
||
setIsProcessing(true);
|
||
|
||
// First stop any current playback
|
||
setIsPlaying(false);
|
||
clearPendingEpubJump();
|
||
abortAudio(true); // Clear pending requests since speed changed
|
||
setActiveHowl(null);
|
||
|
||
// Update audio speed and config
|
||
setAudioSpeed(newSpeed);
|
||
|
||
// Update config after state changes
|
||
updateConfigKey('audioPlayerSpeed', newSpeed).then(() => {
|
||
setIsProcessing(false);
|
||
// Resume playback if it was playing before and this is the latest restart
|
||
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||
setIsPlaying(true);
|
||
}
|
||
});
|
||
}, [abortAudio, updateConfigKey, isPlaying, clearPendingEpubJump]);
|
||
|
||
/**
|
||
* Provides the TTS context value to child components
|
||
*/
|
||
const value = useMemo(() => ({
|
||
isPlaying,
|
||
isProcessing,
|
||
currentSentence: sentences[currentIndex] || '',
|
||
currentSegment: playbackSegments[currentIndex] ?? null,
|
||
sentences,
|
||
currentSentenceIndex: currentIndex,
|
||
currentSentenceAlignment,
|
||
currentWordIndex,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
currDocPages,
|
||
voice,
|
||
availableVoices,
|
||
togglePlay,
|
||
skipForward,
|
||
skipBackward,
|
||
stop,
|
||
pause,
|
||
stopAndPlayFromIndex,
|
||
playFromSegment,
|
||
setText,
|
||
setCurrDocPages,
|
||
setSpeedAndRestart,
|
||
setAudioPlayerSpeedAndRestart,
|
||
setVoiceAndRestart,
|
||
documentLanguage,
|
||
resolvedLanguage,
|
||
setDocumentLanguage,
|
||
clearSegmentCaches,
|
||
skipToLocation,
|
||
registerLocationChangeHandler,
|
||
registerEpubLocationWalker,
|
||
registerEpubLocatorResolver,
|
||
registerVisualPageChangeHandler,
|
||
setIsEPUB,
|
||
activeReaderType,
|
||
}), [
|
||
isPlaying,
|
||
isProcessing,
|
||
sentences,
|
||
playbackSegments,
|
||
currentIndex,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
currDocPages,
|
||
voice,
|
||
availableVoices,
|
||
togglePlay,
|
||
skipForward,
|
||
skipBackward,
|
||
stop,
|
||
pause,
|
||
stopAndPlayFromIndex,
|
||
playFromSegment,
|
||
setText,
|
||
setCurrDocPages,
|
||
setSpeedAndRestart,
|
||
setAudioPlayerSpeedAndRestart,
|
||
setVoiceAndRestart,
|
||
documentLanguage,
|
||
resolvedLanguage,
|
||
clearSegmentCaches,
|
||
skipToLocation,
|
||
registerLocationChangeHandler,
|
||
registerEpubLocationWalker,
|
||
registerEpubLocatorResolver,
|
||
registerVisualPageChangeHandler,
|
||
setIsEPUB,
|
||
currentSentenceAlignment,
|
||
currentWordIndex,
|
||
activeReaderType,
|
||
]);
|
||
|
||
// Use media session hook
|
||
useMediaSession({
|
||
togglePlay,
|
||
skipForward,
|
||
skipBackward,
|
||
});
|
||
|
||
// Load last location on mount for EPUB/PDF/HTML.
|
||
// Prefer server-backed progress when available, then fall back to local Dexie.
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
|
||
let cancelled = false;
|
||
const docId = id as string;
|
||
|
||
const applyLocation = (lastLocation: string) => {
|
||
if (isEPUB && locationChangeHandlerRef.current) {
|
||
// For EPUB documents, use the location change handler
|
||
locationChangeHandlerRef.current(lastLocation);
|
||
return;
|
||
}
|
||
|
||
if (!isEPUB) {
|
||
// HTML stores "html:<location>:<sentenceIndex>".
|
||
// PDF stores "<page>:<sentenceIndex>".
|
||
try {
|
||
if (currentReaderType === 'html') {
|
||
const htmlMatch = /^html:([^:]+):(\d+)$/.exec(lastLocation);
|
||
if (htmlMatch) {
|
||
const [, rawLocation, sentenceIndexStr] = htmlMatch;
|
||
const decodedLocation = decodeURIComponent(rawLocation);
|
||
const parsedNumber = Number(decodedLocation);
|
||
const location: TTSLocation = Number.isFinite(parsedNumber) && decodedLocation.trim() !== ''
|
||
? parsedNumber
|
||
: decodedLocation || 1;
|
||
const sentenceIndex = parseInt(sentenceIndexStr, 10);
|
||
if (!isNaN(sentenceIndex)) {
|
||
setCurrDocPage(location);
|
||
pendingJumpTargetRef.current = {
|
||
locationKey: normalizeLocationKey(location),
|
||
index: Math.max(0, sentenceIndex),
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Backward-compatible parser for legacy non-EPUB progress format.
|
||
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
|
||
const page = parseInt(pageStr, 10);
|
||
const sentenceIndex = parseInt(sentenceIndexStr, 10);
|
||
if (!isNaN(page) && !isNaN(sentenceIndex)) {
|
||
setCurrDocPage(page);
|
||
pendingJumpTargetRef.current = {
|
||
locationKey: normalizeLocationKey(page),
|
||
index: Math.max(0, sentenceIndex),
|
||
};
|
||
}
|
||
} catch (error) {
|
||
console.warn('Error parsing non-EPUB location:', error);
|
||
}
|
||
}
|
||
};
|
||
|
||
const load = async () => {
|
||
try {
|
||
const local = await getLastDocumentLocation(docId);
|
||
if (!cancelled && local) {
|
||
applyLocation(local);
|
||
}
|
||
} catch (error) {
|
||
console.warn('Error loading local last location:', error);
|
||
}
|
||
|
||
try {
|
||
const remote = await getDocumentProgress(docId);
|
||
if (!cancelled && remote?.location) {
|
||
await setLastDocumentLocation(docId, remote.location).catch((error) => {
|
||
console.warn('Error caching remote location locally:', error);
|
||
});
|
||
applyLocation(remote.location);
|
||
}
|
||
} catch (error) {
|
||
console.warn('Error loading remote progress:', error);
|
||
}
|
||
};
|
||
|
||
load();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [id, isEPUB, currentReaderType]);
|
||
|
||
// Save current position periodically for non-EPUB readers.
|
||
useEffect(() => {
|
||
if (id && !isEPUB && sentences.length > 0) {
|
||
const location = currentReaderType === 'html'
|
||
? `html:${encodeURIComponent(String(currDocPage || 1))}:${currentIndex}`
|
||
: `${currDocPageNumber}:${currentIndex}`;
|
||
const timeoutId = setTimeout(() => {
|
||
setLastDocumentLocation(id as string, location).catch(error => {
|
||
console.warn('Error saving non-EPUB location:', error);
|
||
});
|
||
scheduleDocumentProgressSync({
|
||
documentId: id as string,
|
||
readerType: currentReaderType,
|
||
location,
|
||
});
|
||
}, 1000); // Debounce saves by 1 second
|
||
|
||
return () => clearTimeout(timeoutId);
|
||
}
|
||
}, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType]);
|
||
|
||
/**
|
||
* Renders the TTS context provider with its children
|
||
*
|
||
* @param {ReactNode} children - Child components to be wrapped
|
||
* @returns {JSX.Element}
|
||
*/
|
||
return (
|
||
<TTSContext.Provider value={value}>
|
||
{children}
|
||
</TTSContext.Provider>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Custom hook to consume the TTS context
|
||
* Ensures the context is used within a TTSProvider
|
||
*
|
||
* @throws {Error} If used outside of TTSProvider
|
||
* @returns {TTSContextType} The TTS context value
|
||
*/
|
||
export function useTTS() {
|
||
const context = useContext(TTSContext);
|
||
if (context === undefined) {
|
||
throw new Error('useTTS must be used within a TTSProvider');
|
||
}
|
||
return context;
|
||
}
|