feat(tts,config,ui): add advanced segment preloading and block length controls

Introduce configurable segment preloading depth, sentence lookahead, and TTS segment max block length for both PDF and EPUB readers. Add new settings to user preferences, config context, and document settings UI. Refactor TTS segment splitting logic to support per-user max block length and propagate these options through PDF/EPUB adapters and TTS segment generation. Update API, manifest pagination, and segment deduplication for improved performance and correctness. Add tests for new NLP options and manifest logic.

BREAKING CHANGE: TTS segment splitting and manifest APIs now require max block length and preloading parameters; user config schema updated.
This commit is contained in:
Richard R 2026-05-07 12:26:08 -06:00
parent 3862d0b0ad
commit 265e1cb56a
29 changed files with 2475 additions and 602 deletions

View file

@ -48,12 +48,16 @@ export async function POST(request: NextRequest) {
const audioKeys = rows const audioKeys = rows
.map((row) => row.audioKey) .map((row) => row.audioKey)
.filter((key): key is string => Boolean(key)); .filter((key): key is string => Boolean(key));
const uniqueAudioKeys = Array.from(new Set(audioKeys));
let deletedAudioObjects = 0; let deletedAudioObjects = 0;
let warning: string | undefined; let warning: string | undefined;
if (audioKeys.length > 0) { if (uniqueAudioKeys.length > 0) {
try { try {
deletedAudioObjects = await deleteTtsSegmentAudioObjects(audioKeys); deletedAudioObjects = await deleteTtsSegmentAudioObjects(uniqueAudioKeys);
if (deletedAudioObjects < uniqueAudioKeys.length) {
warning = `Deleted ${deletedAudioObjects} of ${uniqueAudioKeys.length} audio objects.`;
}
} catch (error) { } catch (error) {
warning = error instanceof Error ? error.message : 'Failed deleting some audio objects'; warning = error instanceof Error ? error.message : 'Failed deleting some audio objects';
console.warn('Failed clearing some TTS segment audio objects:', { console.warn('Failed clearing some TTS segment audio objects:', {
@ -67,6 +71,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
documentId: parsed.documentId, documentId: parsed.documentId,
deletedSegments: rows.length, deletedSegments: rows.length,
requestedAudioObjects: uniqueAudioKeys.length,
deletedAudioObjects, deletedAudioObjects,
...(warning ? { warning } : {}), ...(warning ? { warning } : {}),
}); });

View file

@ -15,6 +15,8 @@ import {
buildTtsSegmentSettingsHash, buildTtsSegmentSettingsHash,
buildTtsSegmentSettingsJson, buildTtsSegmentSettingsJson,
buildTtsSegmentTextHash, buildTtsSegmentTextHash,
canonicalLocatorJson,
canonicalizeLocatorJsonString,
locatorFingerprint, locatorFingerprint,
normalizeLocator, normalizeLocator,
normalizeSegmentText, normalizeSegmentText,
@ -125,11 +127,22 @@ function buildSegmentAudioUrls(documentId: string, segmentId: string): {
}; };
} }
async function cleanupStaleErroredVariants(input: { function isAbortLikeError(error: unknown): boolean {
const message = error instanceof Error
? error.message
: typeof error === 'string'
? error
: '';
if (!message) return false;
return /abort/i.test(message);
}
async function cleanupStaleCanonicalVariants(input: {
userId: string; userId: string;
documentId: string; documentId: string;
documentVersion: number; documentVersion: number;
segmentIndex: number; segmentIndex: number;
activeLocatorJson: string | null;
activeSegmentId: string; activeSegmentId: string;
activeSettingsHash: string; activeSettingsHash: string;
}): Promise<void> { }): Promise<void> {
@ -138,6 +151,7 @@ async function cleanupStaleErroredVariants(input: {
segmentId: ttsSegments.segmentId, segmentId: ttsSegments.segmentId,
settingsHash: ttsSegments.settingsHash, settingsHash: ttsSegments.settingsHash,
audioKey: ttsSegments.audioKey, audioKey: ttsSegments.audioKey,
locatorJson: ttsSegments.locatorJson,
}) })
.from(ttsSegments) .from(ttsSegments)
.where(and( .where(and(
@ -150,9 +164,14 @@ async function cleanupStaleErroredVariants(input: {
segmentId: string; segmentId: string;
settingsHash: string; settingsHash: string;
audioKey: string | null; audioKey: string | null;
locatorJson: string | null;
}>; }>;
const staleRowsForSettings = staleRows.filter((row) => row.settingsHash === input.activeSettingsHash); const activeCanonicalLocator = canonicalizeLocatorJsonString(input.activeLocatorJson);
const staleRowsForSettings = staleRows.filter((row) =>
row.settingsHash === input.activeSettingsHash
&& canonicalizeLocatorJsonString(row.locatorJson) === activeCanonicalLocator,
);
const staleIds = staleRowsForSettings.map((row) => row.segmentId); const staleIds = staleRowsForSettings.map((row) => row.segmentId);
if (staleIds.length === 0) return; if (staleIds.length === 0) return;
@ -164,9 +183,9 @@ async function cleanupStaleErroredVariants(input: {
inArray(ttsSegments.segmentId, staleIds), inArray(ttsSegments.segmentId, staleIds),
)); ));
const staleAudioKeys = staleRowsForSettings const staleAudioKeys = Array.from(new Set(staleRowsForSettings
.map((row) => row.audioKey) .map((row) => row.audioKey)
.filter((key): key is string => Boolean(key)); .filter((key): key is string => Boolean(key))));
if (staleAudioKeys.length > 0) { if (staleAudioKeys.length > 0) {
try { try {
await deleteTtsSegmentAudioObjects(staleAudioKeys); await deleteTtsSegmentAudioObjects(staleAudioKeys);
@ -263,11 +282,12 @@ export async function POST(request: NextRequest) {
const existing = existingById.get(segment.segmentId); const existing = existingById.get(segment.segmentId);
if (existing?.status === 'completed' && existing.audioKey) { if (existing?.status === 'completed' && existing.audioKey) {
await cleanupStaleErroredVariants({ await cleanupStaleCanonicalVariants({
userId: scope.storageUserId, userId: scope.storageUserId,
documentId: parsed.documentId, documentId: parsed.documentId,
documentVersion: scope.documentVersion, documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex, segmentIndex: segment.original.segmentIndex,
activeLocatorJson: existing.locatorJson,
activeSegmentId: segment.segmentId, activeSegmentId: segment.segmentId,
activeSettingsHash: settingsHash, activeSettingsHash: settingsHash,
}); });
@ -333,6 +353,7 @@ export async function POST(request: NextRequest) {
segmentId: segment.segmentId, segmentId: segment.segmentId,
}); });
const segmentLocatorJson = canonicalLocatorJson(segment.locator);
await db await db
.insert(ttsSegments) .insert(ttsSegments)
.values({ .values({
@ -342,7 +363,7 @@ export async function POST(request: NextRequest) {
readerType: scope.readerType, readerType: scope.readerType,
documentVersion: scope.documentVersion, documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex, segmentIndex: segment.original.segmentIndex,
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null, locatorJson: segmentLocatorJson,
settingsHash, settingsHash,
settingsJson, settingsJson,
textHash: segment.textHash, textHash: segment.textHash,
@ -360,7 +381,7 @@ export async function POST(request: NextRequest) {
readerType: scope.readerType, readerType: scope.readerType,
documentVersion: scope.documentVersion, documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex, segmentIndex: segment.original.segmentIndex,
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null, locatorJson: segmentLocatorJson,
settingsHash, settingsHash,
settingsJson, settingsJson,
textHash: segment.textHash, textHash: segment.textHash,
@ -472,11 +493,12 @@ export async function POST(request: NextRequest) {
}) })
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId))); .where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
await cleanupStaleErroredVariants({ await cleanupStaleCanonicalVariants({
userId: scope.storageUserId, userId: scope.storageUserId,
documentId: parsed.documentId, documentId: parsed.documentId,
documentVersion: scope.documentVersion, documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex, segmentIndex: segment.original.segmentIndex,
activeLocatorJson: canonicalLocatorJson(segment.locator),
activeSegmentId: segment.segmentId, activeSegmentId: segment.segmentId,
activeSettingsHash: settingsHash, activeSettingsHash: settingsHash,
}); });
@ -492,11 +514,12 @@ export async function POST(request: NextRequest) {
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to generate segment'; const message = error instanceof Error ? error.message : 'Failed to generate segment';
const aborted = isAbortLikeError(error);
await db await db
.update(ttsSegments) .update(ttsSegments)
.set({ .set({
status: 'error', status: aborted ? 'pending' : 'error',
error: message, error: aborted ? null : message,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId))); .where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
@ -509,7 +532,7 @@ export async function POST(request: NextRequest) {
durationMs: 0, durationMs: 0,
alignment: null, alignment: null,
locator: segment.locator, locator: segment.locator,
status: 'error', status: aborted ? 'pending' : 'error',
}); });
} }
} }

View file

@ -3,6 +3,14 @@ import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { ttsSegments } from '@/db/schema'; import { ttsSegments } from '@/db/schema';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import {
compareManifestSegments,
decodeManifestCursor,
dedupeManifestVariants,
encodeManifestCursor,
locatorGroupKey,
parseManifestPageSize,
} from '@/lib/server/tts/segments-manifest';
import type { import type {
TTSSegmentLocator, TTSSegmentLocator,
TTSSegmentRow, TTSSegmentRow,
@ -65,45 +73,17 @@ function buildSegmentAudioUrls(documentId: string, segmentId: string): {
}; };
} }
function statusRank(status: TTSSegmentVariant['status']): number { function isAbortLikeMessage(message: string | null | undefined): boolean {
if (status === 'completed') return 3; if (!message) return false;
if (status === 'pending') return 2; return /abort/i.test(message);
return 1;
}
function dedupeVariants(variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>): TTSSegmentVariant[] {
const byKey = new Map<string, TTSSegmentVariant>();
for (const { dedupeKey, variant } of variants) {
const existing = byKey.get(dedupeKey);
if (!existing) {
byKey.set(dedupeKey, variant);
continue;
}
const existingRank = statusRank(existing.status);
const nextRank = statusRank(variant.status);
const existingUpdatedAt = existing.updatedAt ?? 0;
const nextUpdatedAt = variant.updatedAt ?? 0;
if (nextRank > existingRank || (nextRank === existingRank && nextUpdatedAt >= existingUpdatedAt)) {
byKey.set(dedupeKey, variant);
}
}
return Array.from(byKey.values());
}
function locatorGroupKey(locator: TTSSegmentLocator | null): string {
if (!locator) return 'none';
const page = typeof locator.page === 'number' && Number.isFinite(locator.page)
? String(Math.floor(locator.page))
: '';
const location = typeof locator.location === 'string' ? locator.location : '';
const readerType = locator.readerType || '';
return `p:${page}|l:${location}|r:${readerType}`;
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const documentIdRaw = request.nextUrl.searchParams.get('documentId'); const documentIdRaw = request.nextUrl.searchParams.get('documentId');
const documentId = documentIdRaw?.trim().toLowerCase(); const documentId = documentIdRaw?.trim().toLowerCase();
const limit = parseManifestPageSize(request.nextUrl.searchParams.get('limit'));
const cursor = decodeManifestCursor(request.nextUrl.searchParams.get('cursor'));
if (!documentId) { if (!documentId) {
return NextResponse.json({ error: 'Missing documentId' }, { status: 400 }); return NextResponse.json({ error: 'Missing documentId' }, { status: 400 });
} }
@ -171,9 +151,11 @@ export async function GET(request: NextRequest) {
} }
} }
const status: TTSSegmentVariant['status'] = row.status === 'completed' || row.status === 'error' const status: TTSSegmentVariant['status'] = row.status === 'completed'
? row.status ? 'completed'
: 'pending'; : row.status === 'error' && !isAbortLikeMessage(row.error)
? 'error'
: 'pending';
const audioUrls = row.status === 'completed' && row.audioKey const audioUrls = row.status === 'completed' && row.audioKey
? buildSegmentAudioUrls(documentId, row.segmentId) ? buildSegmentAudioUrls(documentId, row.segmentId)
@ -196,22 +178,40 @@ export async function GET(request: NextRequest) {
}); });
} }
const segments = Array.from(grouped.values()) const segments = Array.from(grouped.entries())
.map((segment) => ({ .map(([groupKey, segment]) => ({
groupKey,
segmentIndex: segment.segmentIndex, segmentIndex: segment.segmentIndex,
locator: segment.locator, locator: segment.locator,
variants: dedupeVariants(segment.variants), variants: dedupeManifestVariants(segment.variants),
})) }))
.sort((a, b) => { .sort(compareManifestSegments);
if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex;
const aPage = typeof a.locator?.page === 'number' ? a.locator.page : Number.MAX_SAFE_INTEGER; let startIndex = 0;
const bPage = typeof b.locator?.page === 'number' ? b.locator.page : Number.MAX_SAFE_INTEGER; if (cursor) {
if (aPage !== bPage) return aPage - bPage; const cursorIndex = segments.findIndex((segment) => segment.groupKey === cursor);
const aLoc = a.locator?.location || ''; if (cursorIndex < 0) {
const bLoc = b.locator?.location || ''; return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 });
return aLoc.localeCompare(bLoc); }
}); if (cursorIndex >= 0) startIndex = cursorIndex + 1;
const response: TTSSegmentsManifestResponse = { documentId, segments }; }
const page = segments.slice(startIndex, startIndex + limit);
const hasMore = startIndex + page.length < segments.length;
const nextCursor = hasMore && page.length > 0
? encodeManifestCursor(page[page.length - 1].groupKey)
: null;
const response: TTSSegmentsManifestResponse = {
documentId,
segments: page.map((segment) => ({
segmentIndex: segment.segmentIndex,
locator: segment.locator,
variants: segment.variants,
})),
nextCursor,
hasMore,
};
return NextResponse.json(response); return NextResponse.json(response);
} catch (error) { } catch (error) {
console.error('Error listing TTS segments manifest:', error); console.error('Error listing TTS segments manifest:', error);

View file

@ -62,6 +62,9 @@ function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
break; break;
case 'voiceSpeed': case 'voiceSpeed':
case 'audioPlayerSpeed': case 'audioPlayerSpeed':
case 'segmentPreloadDepthPages':
case 'segmentPreloadSentenceLookahead':
case 'ttsSegmentMaxBlockLength':
case 'headerMargin': case 'headerMargin':
case 'footerMargin': case 'footerMargin':
case 'leftMargin': case 'leftMargin':

View file

@ -464,6 +464,7 @@ export function AudiobookExportModal({
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
ariaLabel="Export audiobook" ariaLabel="Export audiobook"
title="Export Audiobook" title="Export Audiobook"
subtitle="Only leaving the document cancels generation."
> >
{isLoadingExisting ? ( {isLoadingExisting ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
@ -882,9 +883,7 @@ export function AudiobookExportModal({
{chapters.length === 0 && !isGenerating && !isLoadingExisting && ( {chapters.length === 0 && !isGenerating && !isLoadingExisting && (
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Audiobook settings are fixed after generation. Chapters will appear here as they are ready. Audiobook settings are fixed after generation. Chapters will appear here as they are ready.
<br></br>
You can close this dialog while the audiobook is being generated. But returning to the home screen will cancel the generation.
</p> </p>
</div> </div>
)} )}

View file

@ -1,10 +1,20 @@
'use client'; 'use client';
import { Fragment, useState, useEffect } from 'react'; import { useState, useEffect, type ChangeEvent } from 'react';
import { Transition, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react';
import { useConfig, ViewType } from '@/contexts/ConfigContext'; import { useConfig, ViewType } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import {
SEGMENT_PRELOAD_DEPTH_MIN,
SEGMENT_PRELOAD_DEPTH_MAX,
SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN,
SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX,
TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN,
TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX,
TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP,
clampSegmentPreloadDepth,
clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength,
} from '@/types/config';
const canWordHighlight = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false'; const canWordHighlight = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
@ -14,6 +24,81 @@ const viewTypeTextMapping = [
{ id: 'scroll', name: 'Continuous Scroll' }, { id: 'scroll', name: 'Continuous Scroll' },
]; ];
const rangeInputClassName = 'w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent';
type MarginKey = 'header' | 'footer' | 'left' | 'right';
type RangeSettingProps = {
label: string;
value: number;
min: number;
max: number;
step: number;
description: string;
valueWidth?: string;
formatter?: (value: number) => string;
onChange: (value: number) => void;
};
function RangeSetting({
label,
value,
min,
max,
step,
description,
valueWidth = 'w-10',
formatter = (next) => String(next),
onChange,
}: RangeSettingProps) {
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">{label}</label>
<div className="flex items-center gap-3">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className={`flex-1 ${rangeInputClassName}`}
/>
<span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span>
</div>
<p className="text-xs text-muted">{description}</p>
</div>
);
}
type ToggleRowProps = {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
};
function ToggleRow({ label, description, checked, onChange, disabled = false }: ToggleRowProps) {
return (
<div className="rounded-xl border border-offbase bg-background px-3 py-2.5 shadow-sm">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="mt-0.5 form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="space-y-0.5">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs text-muted">{description}</span>
</span>
</label>
</div>
);
}
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
isOpen: boolean, isOpen: boolean,
setIsOpen: (isOpen: boolean) => void, setIsOpen: (isOpen: boolean) => void,
@ -25,6 +110,9 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
skipBlank, skipBlank,
epubTheme, epubTheme,
smartSentenceSplitting, smartSentenceSplitting,
segmentPreloadDepthPages,
segmentPreloadSentenceLookahead,
ttsSegmentMaxBlockLength,
headerMargin, headerMargin,
footerMargin, footerMargin,
leftMargin, leftMargin,
@ -42,6 +130,15 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
right: rightMargin right: rightMargin
}); });
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0]; const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
const [localSentenceLookahead, setLocalSentenceLookahead] = useState(segmentPreloadSentenceLookahead);
const [localMaxBlockLength, setLocalMaxBlockLength] = useState(ttsSegmentMaxBlockLength);
const marginValues: Record<MarginKey, number> = {
header: headerMargin,
footer: footerMargin,
left: leftMargin,
right: rightMargin,
};
useEffect(() => { useEffect(() => {
setLocalMargins({ setLocalMargins({
@ -52,297 +149,235 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
}); });
}, [headerMargin, footerMargin, leftMargin, rightMargin]); }, [headerMargin, footerMargin, leftMargin, rightMargin]);
// Handler for slider change (updates local state only) useEffect(() => {
const handleMarginChange = (margin: keyof typeof localMargins) => (event: React.ChangeEvent<HTMLInputElement>) => { setLocalPreloadDepth(segmentPreloadDepthPages);
setLocalMargins(prev => ({ }, [segmentPreloadDepthPages]);
...prev,
[margin]: Number(event.target.value) useEffect(() => {
})); setLocalSentenceLookahead(segmentPreloadSentenceLookahead);
}; }, [segmentPreloadSentenceLookahead]);
useEffect(() => {
setLocalMaxBlockLength(ttsSegmentMaxBlockLength);
}, [ttsSegmentMaxBlockLength]);
// Handler for slider release // Handler for slider release
const handleMarginChangeComplete = (margin: keyof typeof localMargins) => () => { const handleMarginChangeComplete = (margin: MarginKey) => () => {
const value = localMargins[margin]; const value = localMargins[margin];
const configKey = `${margin}Margin`; const configKey = `${margin}Margin`;
if (value !== (useConfig)[configKey as keyof typeof useConfig]) { if (value !== marginValues[margin]) {
updateConfigKey(configKey as 'headerMargin' | 'footerMargin' | 'leftMargin' | 'rightMargin', value); updateConfigKey(configKey as 'headerMargin' | 'footerMargin' | 'leftMargin' | 'rightMargin', value);
} }
}; };
const handleMarginSliderChange = (margin: MarginKey) => (event: ChangeEvent<HTMLInputElement>) => {
setLocalMargins((previous) => ({
...previous,
[margin]: Number(event.target.value),
}));
};
return ( return (
<ReaderSidebarShell <ReaderSidebarShell
isOpen={isOpen} isOpen={isOpen}
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
ariaLabel="Document settings" ariaLabel="Document settings"
title="Settings" title="Reader Settings"
subtitle="Configure layout, preloading, and playback behavior for this document."
bodyClassName="flex-1 overflow-y-auto px-4 py-4 bg-[radial-gradient(circle_at_top_right,rgba(239,68,68,0.08),transparent_35%)]"
panelClassName="w-full sm:w-[30rem]"
> >
<div className="space-y-4"> <div className="space-y-4">
{!epub && !html && <div className="space-y-6"> {!html && (
<div className="space-y-2"> <section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3">
<label className="block text-sm font-medium text-foreground"> <div>
Text extraction margins <h3 className="text-sm font-semibold text-foreground">Playback Flow</h3>
</label> <p className="text-xs text-muted mt-0.5">Control segment generation and lookahead while audio is active.</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> </div>
{/* Header Margin */}
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-xs">Header</span>
<span className="text-xs font-bold">{Math.round(localMargins.header * 100)}%</span>
</div>
<input
type="range"
min="0"
max="0.2"
step="0.01"
value={localMargins.header}
onChange={handleMarginChange('header')}
onMouseUp={handleMarginChangeComplete('header')}
onKeyUp={handleMarginChangeComplete('header')}
onTouchEnd={handleMarginChangeComplete('header')}
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
/>
</div>
{/* Footer Margin */} <ToggleRow
<div className="space-y-1"> label="Skip blank pages"
<div className="flex justify-between"> description="Automatically skip pages with no readable text."
<span className="text-xs">Footer</span> checked={skipBlank}
<span className="text-xs font-bold">{Math.round(localMargins.footer * 100)}%</span> onChange={(checked) => updateConfigKey('skipBlank', checked)}
</div> />
<input
type="range"
min="0"
max="0.2"
step="0.01"
value={localMargins.footer}
onChange={handleMarginChange('footer')}
onMouseUp={handleMarginChangeComplete('footer')}
onKeyUp={handleMarginChangeComplete('footer')}
onTouchEnd={handleMarginChangeComplete('footer')}
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
/>
</div>
{/* Left Margin */} <ToggleRow
<div className="space-y-1"> label="Smart sentence splitting"
<div className="flex justify-between"> description="Merge sentence fragments across page or section boundaries."
<span className="text-xs">Left</span> checked={smartSentenceSplitting}
<span className="text-xs font-bold">{Math.round(localMargins.left * 100)}%</span> onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
</div> />
<input
type="range"
min="0"
max="0.2"
step="0.01"
value={localMargins.left}
onChange={handleMarginChange('left')}
onMouseUp={handleMarginChangeComplete('left')}
onKeyUp={handleMarginChangeComplete('left')}
onTouchEnd={handleMarginChangeComplete('left')}
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
/>
</div>
{/* Right Margin */} <div className="rounded-xl border border-offbase bg-background px-3 py-3 space-y-3 shadow-sm">
<div className="space-y-1"> <RangeSetting
<div className="flex justify-between"> label="Segment preload depth"
<span className="text-xs">Right</span> value={localPreloadDepth}
<span className="text-xs font-bold">{Math.round(localMargins.right * 100)}%</span> min={SEGMENT_PRELOAD_DEPTH_MIN}
</div> max={SEGMENT_PRELOAD_DEPTH_MAX}
<input step={1}
type="range" description="How many upcoming pages or locations to queue in the background."
min="0" formatter={(value) => String(value)}
max="0.2" onChange={(value) => {
step="0.01" const next = clampSegmentPreloadDepth(value);
value={localMargins.right} setLocalPreloadDepth(next);
onChange={handleMarginChange('right')} void updateConfigKey('segmentPreloadDepthPages', next);
onMouseUp={handleMarginChangeComplete('right')} }}
onKeyUp={handleMarginChangeComplete('right')} />
onTouchEnd={handleMarginChangeComplete('right')}
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
/>
</div>
</div>
<p className="text-xs text-muted mt-2">
Adjust margins to exclude content from edges of the page during text extraction (experimental)
</p>
</div>
<Listbox
value={selectedView}
onChange={(newView) => updateConfigKey('viewType', newView.id as ViewType)}
>
<div className="relative z-10 space-y-2">
<label className="block text-sm font-medium text-foreground">Mode</label>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
<span className="block truncate">{selectedView.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
{viewTypeTextMapping.map((view) => (
<ListboxOption
key={view.id}
className={({ active }) =>
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
}`
}
value={view}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{view.name}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
{selectedView.id === 'scroll' && (
<p className="text-sm text-warning pt-2">
Note: Continuous scroll may perform poorly for larger documents.
</p>
)}
</div>
</Listbox>
</div>} <RangeSetting
label="Segment lookahead per page/location"
value={localSentenceLookahead}
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
step={1}
description="How many segments to ensure from each queued page or section."
formatter={(value) => String(value)}
onChange={(value) => {
const next = clampSegmentPreloadSentenceLookahead(value);
setLocalSentenceLookahead(next);
void updateConfigKey('segmentPreloadSentenceLookahead', next);
}}
/>
{!html && <div className="space-y-1"> <RangeSetting
<label className="flex items-center space-x-2"> label="TTS segment max block length"
<input value={localMaxBlockLength}
type="checkbox" min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN}
checked={skipBlank} max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX}
onChange={(e) => updateConfigKey('skipBlank', e.target.checked)} step={TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP}
className="form-checkbox h-4 w-4 text-accent rounded border-muted" description="Maximum character count used when chunking text into segment blocks."
/> valueWidth="w-14"
<span className="text-sm font-medium text-foreground">Skip blank pages</span> formatter={(value) => String(value)}
</label> onChange={(value) => {
<p className="text-sm text-muted pl-6"> const next = clampTtsSegmentMaxBlockLength(value);
Automatically skip pages with no text content setLocalMaxBlockLength(next);
</p> void updateConfigKey('ttsSegmentMaxBlockLength', next);
</div>} }}
{!html && ( />
<div className="space-y-1"> </div>
<label className="flex items-center space-x-2"> </section>
<input )}
type="checkbox"
checked={smartSentenceSplitting} {!epub && !html && (
onChange={(e) => updateConfigKey('smartSentenceSplitting', e.target.checked)} <section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3">
className="form-checkbox h-4 w-4 text-accent rounded border-muted" <div>
/> <h3 className="text-sm font-semibold text-foreground">PDF Layout & Extraction</h3>
<span className="text-sm font-medium text-foreground"> <p className="text-xs text-muted mt-0.5">Set viewer mode and trim page edges before extraction.</p>
Smart sentence splitting </div>
</span>
</label> <div className="space-y-1.5">
<p className="text-sm text-muted pl-6"> <label className="block text-sm font-medium text-foreground">Page mode</label>
Merge sentences across page or section breaks <div
</p> role="radiogroup"
</div> aria-label="Page mode"
)} className="grid grid-cols-3 gap-1 rounded-full border border-offbase bg-background p-1"
{!epub && !html && ( >
<div className="space-y-2"> {viewTypeTextMapping.map((view) => {
<div className="space-y-1"> const active = selectedView.id === view.id;
<label className="flex items-center space-x-2"> return (
<input <button
type="checkbox" key={view.id}
checked={pdfHighlightEnabled} type="button"
onChange={(e) => updateConfigKey('pdfHighlightEnabled', e.target.checked)} role="radio"
className="form-checkbox h-4 w-4 text-accent rounded border-muted" aria-checked={active}
/> onClick={() => updateConfigKey('viewType', view.id as ViewType)}
<span className="text-sm font-medium text-foreground">Highlight text during playback</span> className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
</label> active
<p className="text-sm text-muted pl-6"> ? 'bg-accent text-background shadow-sm'
Visual text playback highlighting in the PDF viewer : 'text-muted hover:bg-base hover:text-foreground'
</p> }`}
</div> >
<div className="space-y-1 pl-6"> {view.name}
<label className="flex items-center space-x-2"> </button>
<input );
type="checkbox" })}
checked={pdfWordHighlightEnabled && pdfHighlightEnabled} </div>
disabled={!pdfHighlightEnabled || !canWordHighlight} {selectedView.id === 'scroll' ? (
onChange={(e) => <p className="text-xs text-warning">Continuous scroll may perform poorly for very large PDFs.</p>
updateConfigKey('pdfWordHighlightEnabled', e.target.checked) ) : null}
} </div>
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
/> <div className="rounded-xl border border-offbase bg-background px-3 py-3 shadow-sm">
<span className="text-sm font-medium text-foreground"> <p className="text-xs font-medium text-foreground">Text extraction margins</p>
Word-by-word <p className="text-xs text-muted mt-0.5">
</span> Exclude content near edges before sentence extraction.
</label> </p>
<p className="text-sm text-muted pl-6"> <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'} {(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => (
</p> <div key={margin} className="space-y-1">
</div> <div className="flex items-center justify-between text-xs">
</div> <span className="capitalize text-foreground">{margin}</span>
)} <span className="font-semibold text-foreground">{Math.round(localMargins[margin] * 100)}%</span>
{epub && ( </div>
<div className="space-y-2"> <input
<div className="space-y-1"> type="range"
<label className="flex items-center space-x-2"> min="0"
<input max="0.2"
type="checkbox" step="0.01"
checked={epubHighlightEnabled} value={localMargins[margin]}
onChange={(e) => updateConfigKey('epubHighlightEnabled', e.target.checked)} onChange={handleMarginSliderChange(margin)}
className="form-checkbox h-4 w-4 text-accent rounded border-muted" onMouseUp={handleMarginChangeComplete(margin)}
/> onKeyUp={handleMarginChangeComplete(margin)}
<span className="text-sm font-medium text-foreground">Highlight text during playback</span> onTouchEnd={handleMarginChangeComplete(margin)}
</label> className={rangeInputClassName}
<p className="text-sm text-muted pl-6"> />
Visual text playback highlighting in the EPUB viewer </div>
</p> ))}
</div> </div>
<div className="space-y-1 pl-6"> </div>
<label className="flex items-center space-x-2"> </section>
<input )}
type="checkbox"
checked={epubWordHighlightEnabled && epubHighlightEnabled} {!epub && !html && (
disabled={!epubHighlightEnabled || !canWordHighlight} <section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2">
onChange={(e) => <div>
updateConfigKey('epubWordHighlightEnabled', e.target.checked) <h3 className="text-sm font-semibold text-foreground">PDF Highlighting</h3>
} <p className="text-xs text-muted mt-0.5">Control playback highlighting behavior in PDF mode.</p>
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed" </div>
/> <ToggleRow
<span className="text-sm font-medium text-foreground"> label="Highlight text during playback"
Word-by-word description="Visual sentence-level playback highlighting in the PDF viewer."
</span> checked={pdfHighlightEnabled}
</label> onChange={(checked) => updateConfigKey('pdfHighlightEnabled', checked)}
<p className="text-sm text-muted pl-6"> />
Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'} <ToggleRow
</p> label="Word-by-word highlighting"
</div> description={`Use whisper.cpp timing data to highlight words as speech progresses${!canWordHighlight ? ' (disabled by configuration)' : ''}.`}
</div> checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
)} disabled={!pdfHighlightEnabled || !canWordHighlight}
{epub && ( onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)}
<div className="space-y-1"> />
<label className="flex items-center space-x-2"> </section>
<input )}
type="checkbox"
checked={epubTheme} {epub && (
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)} <section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2">
className="form-checkbox h-4 w-4 text-accent rounded border-muted" <div>
/> <h3 className="text-sm font-semibold text-foreground">EPUB Appearance</h3>
<span className="text-sm font-medium text-foreground">Use theme</span> <p className="text-xs text-muted mt-0.5">Apply app styling and playback highlighting in EPUB mode.</p>
</label> </div>
<p className="text-sm text-muted pl-6"> <ToggleRow
Apply the current app theme to the EPUB viewer background and text colors label="Apply app theme"
</p> description="Use selected theme on EPUB documents. May require refresh."
</div> checked={epubTheme}
)} onChange={(checked) => updateConfigKey('epubTheme', checked)}
/>
<ToggleRow
label="Highlight text during playback"
description="Visual sentence-level playback highlighting in the EPUB viewer."
checked={epubHighlightEnabled}
onChange={(checked) => updateConfigKey('epubHighlightEnabled', checked)}
/>
<ToggleRow
label="Word-by-word highlighting"
description={`Use whisper.cpp timing data to highlight words as speech progresses${!canWordHighlight ? ' (disabled by configuration)' : ''}.`}
checked={epubWordHighlightEnabled && epubHighlightEnabled}
disabled={!epubHighlightEnabled || !canWordHighlight}
onChange={(checked) => updateConfigKey('epubWordHighlightEnabled', checked)}
/>
</section>
)}
</div> </div>
</ReaderSidebarShell> </ReaderSidebarShell>
); );

View file

@ -10,6 +10,7 @@ interface ReaderSidebarShellProps {
onClose: () => void; onClose: () => void;
ariaLabel: string; ariaLabel: string;
title: string; title: string;
subtitle?: string;
children: ReactNode; children: ReactNode;
headerActions?: ReactNode; headerActions?: ReactNode;
footer?: ReactNode; footer?: ReactNode;
@ -22,6 +23,7 @@ export function ReaderSidebarShell({
onClose, onClose,
ariaLabel, ariaLabel,
title, title,
subtitle,
children, children,
headerActions, headerActions,
footer, footer,
@ -70,6 +72,7 @@ export function ReaderSidebarShell({
<div className="border-b border-offbase px-4 py-3 flex items-start justify-between gap-3"> <div className="border-b border-offbase px-4 py-3 flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-sm font-medium text-foreground">{title}</h2> <h2 className="text-sm font-medium text-foreground">{title}</h2>
{subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{headerActions} {headerActions}

View file

@ -2,10 +2,12 @@
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'; import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { locatorGroupKey, normalizeEpubLocationToken } from '@/lib/shared/tts-locator';
import type { import type {
TTSSegmentLocator, TTSSegmentLocator,
TTSSegmentRow, TTSSegmentRow,
@ -23,9 +25,18 @@ interface SegmentsSidebarProps {
type FetchState = type FetchState =
| { kind: 'idle' } | { kind: 'idle' }
| { kind: 'loading' } | { kind: 'loading' }
| { kind: 'ready'; data: TTSSegmentRow[]; fetchedAt: number } | {
kind: 'ready';
data: TTSSegmentRow[];
fetchedAt: number;
nextCursor: string | null;
hasMore: boolean;
loadingMore: boolean;
}
| { kind: 'error'; message: string }; | { kind: 'error'; message: string };
const MANIFEST_PAGE_SIZE = 150;
function formatDuration(ms: number | null | undefined): string { function formatDuration(ms: number | null | undefined): string {
if (!ms || !Number.isFinite(ms) || ms <= 0) return '—'; if (!ms || !Number.isFinite(ms) || ms <= 0) return '—';
const sec = ms / 1000; const sec = ms / 1000;
@ -83,6 +94,10 @@ function locatorMatchesCurrent(
): boolean { ): boolean {
if (!locator) return false; if (!locator) return false;
if (typeof locator.location === 'string' && locator.location.length > 0) { if (typeof locator.location === 'string' && locator.location.length > 0) {
if (locator.readerType === 'epub') {
if (typeof currentLocation !== 'string') return false;
return normalizeEpubLocationToken(locator.location) === normalizeEpubLocationToken(currentLocation);
}
return String(locator.location) === String(currentLocation); return String(locator.location) === String(currentLocation);
} }
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) { if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
@ -91,11 +106,71 @@ function locatorMatchesCurrent(
return false; return false;
} }
function latestUpdatedAt(row: TTSSegmentRow): number { function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string {
return row.variants.reduce((max, variant) => { if (!locator) return 'Unknown location';
const updated = typeof variant.updatedAt === 'number' ? variant.updatedAt : 0; const parts: string[] = [];
return Math.max(max, updated); if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
}, 0); parts.push(`Page ${Math.floor(locator.page)}`);
}
if (typeof locator.location === 'string' && locator.location) {
parts.push(locator.location);
}
if (locator.readerType) {
parts.push(locator.readerType.toUpperCase());
}
return parts.join(' · ') || 'Unknown location';
}
function compareRows(a: TTSSegmentRow, b: TTSSegmentRow): number {
const aPage = typeof a.locator?.page === 'number' ? a.locator.page : Number.MAX_SAFE_INTEGER;
const bPage = typeof b.locator?.page === 'number' ? b.locator.page : Number.MAX_SAFE_INTEGER;
if (aPage !== bPage) return aPage - bPage;
const aLoc = a.locator?.location || '';
const bLoc = b.locator?.location || '';
const byLocation = aLoc.localeCompare(bLoc);
if (byLocation !== 0) return byLocation;
if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex;
return locatorGroupKey(a.locator).localeCompare(locatorGroupKey(b.locator));
}
function mergeRows(existing: TTSSegmentRow[], incoming: TTSSegmentRow[]): TTSSegmentRow[] {
const map = new Map<string, TTSSegmentRow>();
const upsert = (row: TTSSegmentRow) => {
const key = `${row.segmentIndex}|${locatorGroupKey(row.locator)}`;
const prev = map.get(key);
if (!prev) {
map.set(key, row);
return;
}
const bySegmentId = new Map<string, TTSSegmentVariant>();
for (const variant of prev.variants) bySegmentId.set(variant.segmentId, variant);
for (const variant of row.variants) bySegmentId.set(variant.segmentId, variant);
map.set(key, {
...row,
variants: Array.from(bySegmentId.values()),
});
};
existing.forEach(upsert);
incoming.forEach(upsert);
return Array.from(map.values()).sort(compareRows);
}
function findScrollableAncestor(node: HTMLElement, fallback: HTMLElement | null): HTMLElement | null {
let current: HTMLElement | null = node.parentElement;
while (current) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY.toLowerCase();
const canScroll = (overflowY === 'auto' || overflowY === 'scroll') && current.scrollHeight > current.clientHeight + 1;
if (canScroll) return current;
current = current.parentElement;
}
return fallback;
}
function isElementFullyVisibleWithinContainer(element: HTMLElement, container: HTMLElement): boolean {
const elRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom;
} }
export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSidebarProps) { export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSidebarProps) {
@ -105,14 +180,15 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
currDocPage, currDocPage,
currDocPageNumber, currDocPageNumber,
isPlaying, isPlaying,
stopAndPlayFromIndex, playFromSegment,
} = useTTS(); } = useTTS();
const { ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions, updateConfigKey } = useConfig(); const { ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions, updateConfigKey } = useConfig();
const [state, setState] = useState<FetchState>({ kind: 'idle' }); const [state, setState] = useState<FetchState>({ kind: 'idle' });
const [isClearing, setIsClearing] = useState(false); const [isClearing, setIsClearing] = useState(false);
const [clearError, setClearError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const didAutoScrollOnOpenRef = useRef(false);
const activeSettings = useMemo<TTSSegmentSettings>(() => ({ const activeSettings = useMemo<TTSSegmentSettings>(() => ({
ttsProvider, ttsProvider,
@ -122,15 +198,33 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
ttsInstructions: ttsInstructions || '', ttsInstructions: ttsInstructions || '',
}), [ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions]); }), [ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions]);
const loadManifest = useCallback(async () => { const loadManifest = useCallback(async (
mode: 'reset' | 'append' = 'reset',
cursorOverride: string | null = null,
) => {
if (!documentId) return; if (!documentId) return;
const cursor = mode === 'append' ? cursorOverride : null;
if (mode === 'append' && !cursor) return;
abortRef.current?.abort(); abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
setState({ kind: 'loading' }); if (mode === 'reset') {
setState({ kind: 'loading' });
} else {
setState((prev) => {
if (prev.kind !== 'ready') return prev;
return { ...prev, loadingMore: true };
});
}
try { try {
const params = new URLSearchParams({
documentId,
limit: String(MANIFEST_PAGE_SIZE),
});
if (cursor) params.set('cursor', cursor);
const res = await fetch( const res = await fetch(
`/api/tts/segments/manifest?documentId=${encodeURIComponent(documentId)}`, `/api/tts/segments/manifest?${params.toString()}`,
{ signal: controller.signal, cache: 'no-store' }, { signal: controller.signal, cache: 'no-store' },
); );
if (!res.ok) { if (!res.ok) {
@ -138,33 +232,71 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
throw new Error(body || `Request failed (${res.status})`); throw new Error(body || `Request failed (${res.status})`);
} }
const data = (await res.json()) as TTSSegmentsManifestResponse; const data = (await res.json()) as TTSSegmentsManifestResponse;
setState({ kind: 'ready', data: data.segments, fetchedAt: Date.now() }); setState((prev) => {
const merged = mode === 'append' && prev.kind === 'ready'
? mergeRows(prev.data, data.segments)
: mergeRows([], data.segments);
return {
kind: 'ready',
data: merged,
fetchedAt: Date.now(),
nextCursor: data.nextCursor,
hasMore: data.hasMore,
loadingMore: false,
};
});
} catch (err) { } catch (err) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
if (mode === 'append' && err instanceof Error && err.message.includes('Invalid cursor')) {
setState((prev) => {
if (prev.kind !== 'ready') return prev;
return {
...prev,
loadingMore: false,
hasMore: false,
nextCursor: null,
};
});
return;
}
setState({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to load' }); setState({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to load' });
} }
}, [documentId]); }, [documentId]);
const handleClearCache = useCallback(async () => { const handleClearCache = useCallback(async () => {
if (!documentId || isClearing) return; if (!documentId || isClearing) return;
const confirmed = window.confirm('Clear cached segments for this document? This removes generated audio and metadata for listed segments.'); const confirmed = window.confirm('Clear cached segments for this document version? This removes stored segment metadata and audio objects.');
if (!confirmed) return; if (!confirmed) return;
setIsClearing(true); setIsClearing(true);
setClearError(null);
try { try {
const res = await fetch('/api/tts/segments/clear', { const res = await fetch('/api/tts/segments/clear', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId }), body: JSON.stringify({ documentId }),
}); });
const payload = (await res.json().catch(() => null)) as {
error?: string;
deletedSegments?: number;
requestedAudioObjects?: number;
deletedAudioObjects?: number;
warning?: string;
} | null;
if (!res.ok) { if (!res.ok) {
const body = await res.text().catch(() => ''); throw new Error(payload?.error || `Request failed (${res.status})`);
throw new Error(body || `Request failed (${res.status})`);
} }
await loadManifest();
if (payload?.warning) {
toast.error(`Segments cleared, but audio cleanup was partial: ${payload.warning}`);
} else if (payload) {
const deletedSegments = Number(payload.deletedSegments ?? 0);
const deletedAudioObjects = Number(payload.deletedAudioObjects ?? 0);
const requestedAudioObjects = Number(payload.requestedAudioObjects ?? deletedAudioObjects);
toast.success(`Cleared ${deletedSegments} segments and ${deletedAudioObjects}/${requestedAudioObjects} audio objects.`);
}
await loadManifest('reset');
} catch (error) { } catch (error) {
setClearError(error instanceof Error ? error.message : 'Failed to clear segments cache'); toast.error(error instanceof Error ? error.message : 'Failed to clear segments cache');
} finally { } finally {
setIsClearing(false); setIsClearing(false);
} }
@ -172,12 +304,31 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
void loadManifest(); void loadManifest('reset');
return () => { return () => {
abortRef.current?.abort(); abortRef.current?.abort();
}; };
}, [isOpen, loadManifest]); }, [isOpen, loadManifest]);
useEffect(() => {
if (!isOpen) return;
const node = listRef.current;
if (!node) return;
const onScroll = () => {
setState((prev) => {
if (prev.kind !== 'ready' || !prev.hasMore || prev.loadingMore) return prev;
const distance = node.scrollHeight - node.scrollTop - node.clientHeight;
if (distance > 280) return prev;
void loadManifest('append', prev.nextCursor);
return { ...prev, loadingMore: true };
});
};
node.addEventListener('scroll', onScroll);
return () => node.removeEventListener('scroll', onScroll);
}, [isOpen, loadManifest]);
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => { const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
if (!settings) return; if (!settings) return;
await Promise.all([ await Promise.all([
@ -189,61 +340,132 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
]); ]);
}, [updateConfigKey]); }, [updateConfigKey]);
const handleJump = useCallback((index: number) => { const handleRefresh = useCallback(() => {
stopAndPlayFromIndex(index); didAutoScrollOnOpenRef.current = false;
}, [stopAndPlayFromIndex]); void loadManifest('reset');
}, [loadManifest]);
const segmentsByIndex = useMemo(() => { const handleJump = useCallback((index: number, locator: TTSSegmentLocator | null) => {
if (state.kind !== 'ready') return new Map<number, TTSSegmentRow>(); playFromSegment(index, locator);
const map = new Map<number, { row: TTSSegmentRow; score: number; updatedAt: number }>(); }, [playFromSegment]);
for (const row of state.data) {
const isCurrent = locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber);
const score = isCurrent ? 2 : row.locator ? 0 : 1;
if (score === 0) continue;
const candidateUpdatedAt = latestUpdatedAt(row); const rowsToRender = useMemo(() => {
const existing = map.get(row.segmentIndex); if (state.kind !== 'ready') return [] as Array<{
if (!existing) { segmentIndex: number;
map.set(row.segmentIndex, { row, score, updatedAt: candidateUpdatedAt }); sentenceText: string;
continue; row: TTSSegmentRow;
isCurrentLocation: boolean;
groupKey: string;
groupLabel: string;
}>;
const currentRowsFromManifest = state.data.filter((row) =>
locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber),
);
const nonCurrentRows = state.data.filter((row) =>
!locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber),
);
const inferredCurrentLocator = (() => {
const first = currentRowsFromManifest[0]?.locator;
if (first) return first;
if (typeof currDocPage === 'string' && currDocPage.length > 0) {
return {
location: currDocPage,
readerType: 'epub' as const,
};
} }
if (typeof currDocPageNumber === 'number' && Number.isFinite(currDocPageNumber)) {
return {
page: Math.floor(currDocPageNumber),
readerType: 'pdf' as const,
};
}
return null;
})();
if (score > existing.score || (score === existing.score && candidateUpdatedAt >= existing.updatedAt)) { const variantsByIndex = new Map<number, TTSSegmentVariant[]>();
map.set(row.segmentIndex, { row, score, updatedAt: candidateUpdatedAt }); for (const row of currentRowsFromManifest) {
if (!variantsByIndex.has(row.segmentIndex)) {
variantsByIndex.set(row.segmentIndex, []);
}
const merged = variantsByIndex.get(row.segmentIndex)!;
const seenIds = new Set(merged.map((variant) => variant.segmentId));
for (const variant of row.variants ?? []) {
if (seenIds.has(variant.segmentId)) continue;
seenIds.add(variant.segmentId);
merged.push(variant);
} }
} }
const selected = new Map<number, TTSSegmentRow>(); const currentRows: TTSSegmentRow[] = sentences.map((_, segmentIndex) => ({
for (const [idx, entry] of map) selected.set(idx, entry.row); segmentIndex,
return selected; locator: inferredCurrentLocator,
}, [state, currDocPage, currDocPageNumber]); variants: variantsByIndex.get(segmentIndex) ?? [],
}));
const indicesToRender = useMemo(() => { const mergedRows = [...currentRows, ...nonCurrentRows].sort(compareRows);
const indices = new Set<number>(); return mergedRows.map((row) => {
for (let i = 0; i < sentences.length; i += 1) indices.add(i); const isCurrentLocation = locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber);
if (state.kind === 'ready') { return {
for (const row of state.data) { segmentIndex: row.segmentIndex,
if (Number.isInteger(row.segmentIndex) && row.segmentIndex >= 0) { sentenceText: isCurrentLocation ? (sentences[row.segmentIndex] ?? '') : '',
indices.add(row.segmentIndex); row,
} isCurrentLocation,
} groupKey: locatorGroupKey(row.locator),
} groupLabel: formatLocatorGroupLabel(row.locator),
return Array.from(indices).sort((a, b) => a - b); };
}, [sentences, state]);
const rowsToRender: Array<{ segmentIndex: number; sentenceText: string; row: TTSSegmentRow | null }> = [];
for (const i of indicesToRender) {
rowsToRender.push({
segmentIndex: i,
sentenceText: sentences[i] ?? '',
row: segmentsByIndex.get(i) ?? null,
}); });
} }, [state, currDocPage, currDocPageNumber, sentences]);
const totalVariants = state.kind === 'ready' const totalVariants = state.kind === 'ready'
? state.data.reduce((sum, r) => sum + r.variants.length, 0) ? rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0)
: 0; : 0;
useEffect(() => {
if (!isOpen) {
didAutoScrollOnOpenRef.current = false;
return;
}
if (didAutoScrollOnOpenRef.current) return;
if (state.kind !== 'ready' || rowsToRender.length === 0) return;
const container = listRef.current;
if (!container) return;
const activeRow = container.querySelector<HTMLElement>('[data-active-segment="true"]');
if (!activeRow) return;
requestAnimationFrame(() => {
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
didAutoScrollOnOpenRef.current = true;
});
}, [isOpen, state.kind, rowsToRender.length, currentSentenceIndex]);
useEffect(() => {
if (!isOpen || !isPlaying) return;
if (state.kind !== 'ready' || rowsToRender.length === 0) return;
const root = listRef.current;
if (!root) return;
const activeRow = root.querySelector<HTMLElement>('[data-active-segment="true"]');
if (!activeRow) return;
const scrollContainer = findScrollableAncestor(activeRow, root);
if (!scrollContainer) return;
if (isElementFullyVisibleWithinContainer(activeRow, scrollContainer)) return;
requestAnimationFrame(() => {
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
});
}, [
isOpen,
isPlaying,
state.kind,
rowsToRender.length,
currentSentenceIndex,
currDocPage,
currDocPageNumber,
]);
const headerActions = ( const headerActions = (
<> <>
<button <button
@ -258,7 +480,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
</button> </button>
<button <button
type="button" type="button"
onClick={() => void loadManifest()} onClick={handleRefresh}
aria-label="Refresh segments" aria-label="Refresh segments"
title="Refresh" title="Refresh"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-offbase bg-base text-muted hover:bg-offbase hover:text-accent transition-colors" className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-offbase bg-base text-muted hover:bg-offbase hover:text-accent transition-colors"
@ -268,36 +490,29 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
</> </>
); );
const footer = (
<div className="border-t border-offbase px-4 py-2">
<p className="text-xs text-muted leading-relaxed">
Click an index or sentence to jump. Click a voice label to switch the active voice.
</p>
{clearError && (
<p className="mt-1 text-xs text-red-500">
{clearError}
</p>
)}
</div>
);
return ( return (
<ReaderSidebarShell <ReaderSidebarShell
isOpen={isOpen} isOpen={isOpen}
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
ariaLabel="TTS segments" ariaLabel="TTS segments"
title="Segments" title="Segments"
subtitle="Click an index or sentence to jump. Click a voice label to switch the active voice."
headerActions={headerActions} headerActions={headerActions}
footer={footer}
bodyClassName="flex-1 overflow-y-auto px-0 py-0" bodyClassName="flex-1 overflow-y-auto px-0 py-0"
> >
<div className="px-4 py-2 border-b border-offbase"> <div className="px-4 py-2 border-b border-offbase">
<div className="text-xs text-muted"> <div className="text-xs text-muted">
{state.kind === 'ready' ? ( {state.kind === 'ready' ? (
<> <>
{state.data.length} indexed {rowsToRender.length} indexed
<span> · </span> <span> · </span>
{totalVariants} variants {totalVariants} variants
{state.hasMore ? (
<>
<span> · </span>
more
</>
) : null}
</> </>
) : state.kind === 'loading' ? ( ) : state.kind === 'loading' ? (
<span>Loading</span> <span>Loading</span>
@ -309,7 +524,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div ref={listRef} className="flex-1 overflow-y-auto">
{state.kind === 'error' && ( {state.kind === 'error' && (
<div className="px-4 py-6 text-sm text-red-500">{state.message}</div> <div className="px-4 py-6 text-sm text-red-500">{state.message}</div>
)} )}
@ -328,27 +543,48 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
)} )}
{state.kind === 'ready' && rowsToRender.length > 0 && ( {state.kind === 'ready' && rowsToRender.length > 0 && (
<ul className="divide-y divide-offbase"> <ul className="divide-y divide-offbase">
{rowsToRender.map(({ segmentIndex, sentenceText, row }) => { {rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel }, rowIndex) => {
const isCurrent = segmentIndex === currentSentenceIndex; const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null;
const variants = row?.variants ?? []; const showGroupHeader = previousGroupKey !== groupKey;
const isCurrent = isCurrentLocation && segmentIndex === currentSentenceIndex;
const variants = row.variants ?? [];
const bestVariant = variants
.slice()
.sort((a, b) => {
const rank = (status: TTSSegmentVariant['status']) => {
if (status === 'completed') return 3;
if (status === 'pending') return 2;
return 1;
};
const byRank = rank(b.status) - rank(a.status);
if (byRank !== 0) return byRank;
return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
})[0] ?? null;
const activeVariant = variants.find((v) => settingsAreEqual(v.settings, activeSettings)) const activeVariant = variants.find((v) => settingsAreEqual(v.settings, activeSettings))
?? variants[0] ?? bestVariant;
?? null;
const status = activeVariant?.status ?? 'pending'; const status = activeVariant?.status ?? 'pending';
const canJump = sentenceText.length > 0; const canJump = !!row.locator || sentenceText.length > 0;
const playable = !!(activeVariant && activeVariant.audioPresignUrl); const playable = !!(activeVariant && activeVariant.audioPresignUrl);
return ( return (
<li <li
key={segmentIndex} key={`${groupKey}::${segmentIndex}`}
data-active-segment={isCurrent ? 'true' : undefined}
className={`relative px-4 py-3 ${isCurrent ? 'bg-offbase/40' : ''}`} className={`relative px-4 py-3 ${isCurrent ? 'bg-offbase/40' : ''}`}
> >
{showGroupHeader && (
<div className="mb-2 -mx-4 px-4 py-1.5 bg-offbase/30 border-y border-offbase">
<span className="text-[10px] uppercase tracking-[0.14em] text-muted">
{groupLabel}
</span>
</div>
)}
{isCurrent && ( {isCurrent && (
<span className="absolute inset-y-2 left-0 w-0.5 bg-accent rounded-r" aria-hidden /> <span className="absolute inset-y-2 left-0 w-0.5 bg-accent rounded-r" aria-hidden />
)} )}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<button <button
type="button" type="button"
onClick={() => { if (canJump) handleJump(segmentIndex); }} onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }}
disabled={!canJump} disabled={!canJump}
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-muted hover:text-accent' : 'text-muted/50 cursor-not-allowed'}`} className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-muted hover:text-accent' : 'text-muted/50 cursor-not-allowed'}`}
title={canJump ? (playable ? 'Play this segment' : 'Jump to this segment') : 'Text not loaded yet'} title={canJump ? (playable ? 'Play this segment' : 'Jump to this segment') : 'Text not loaded yet'}
@ -360,7 +596,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<button <button
type="button" type="button"
onClick={() => { if (canJump) handleJump(segmentIndex); }} onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }}
disabled={!canJump} disabled={!canJump}
className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`} className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`}
> >
@ -427,15 +663,16 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
</div> </div>
</div> </div>
{row && ( <SegmentMetadataPopover row={row} />
<SegmentMetadataPopover row={row} />
)}
</div> </div>
</li> </li>
); );
})} })}
</ul> </ul>
)} )}
{state.kind === 'ready' && state.loadingMore && (
<div className="px-4 py-3 text-xs text-muted">Loading more segments</div>
)}
</div> </div>
</ReaderSidebarShell> </ReaderSidebarShell>
); );

View file

@ -36,10 +36,12 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
highlightPattern, highlightPattern,
clearHighlights, clearHighlights,
highlightWordIndex, highlightWordIndex,
clearWordHighlights clearWordHighlights,
walkUpcomingRenderedLocations,
} = useEPUB(); } = useEPUB();
const { const {
registerLocationChangeHandler, registerLocationChangeHandler,
registerEpubLocationWalker,
pause, pause,
currentSentence, currentSentence,
currentSentenceAlignment, currentSentenceAlignment,
@ -71,7 +73,12 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
// Register the location change handler // Register the location change handler
useEffect(() => { useEffect(() => {
registerLocationChangeHandler(handleLocationChanged); registerLocationChangeHandler(handleLocationChanged);
}, [registerLocationChangeHandler, handleLocationChanged]); registerEpubLocationWalker(walkUpcomingRenderedLocations);
return () => {
registerLocationChangeHandler(null);
registerEpubLocationWalker(null);
};
}, [registerLocationChangeHandler, registerEpubLocationWalker, handleLocationChanged, walkUpcomingRenderedLocations]);
// Handle highlighting // Handle highlighting
useEffect(() => { useEffect(() => {
@ -132,7 +139,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
</button> </button>
<button <button
type="button" type="button"
onClick={() => renditionRef.current?.prev()} onClick={() => handleLocationChanged('prev')}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent" className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label="Previous section" aria-label="Previous section"
> >
@ -146,7 +153,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
)} )}
<button <button
type="button" type="button"
onClick={() => renditionRef.current?.next()} onClick={() => handleLocationChanged('next')}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent" className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label="Next section" aria-label="Next section"
> >

View file

@ -26,6 +26,9 @@ interface ConfigContextType {
skipBlank: boolean; skipBlank: boolean;
epubTheme: boolean; epubTheme: boolean;
smartSentenceSplitting: boolean; smartSentenceSplitting: boolean;
segmentPreloadDepthPages: number;
segmentPreloadSentenceLookahead: number;
ttsSegmentMaxBlockLength: number;
headerMargin: number; headerMargin: number;
footerMargin: number; footerMargin: number;
leftMargin: number; leftMargin: number;
@ -278,6 +281,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
ttsInstructions, ttsInstructions,
savedVoices, savedVoices,
smartSentenceSplitting, smartSentenceSplitting,
segmentPreloadDepthPages,
segmentPreloadSentenceLookahead,
ttsSegmentMaxBlockLength,
pdfHighlightEnabled, pdfHighlightEnabled,
pdfWordHighlightEnabled, pdfWordHighlightEnabled,
epubHighlightEnabled, epubHighlightEnabled,
@ -354,6 +360,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
skipBlank, skipBlank,
epubTheme, epubTheme,
smartSentenceSplitting, smartSentenceSplitting,
segmentPreloadDepthPages,
segmentPreloadSentenceLookahead,
ttsSegmentMaxBlockLength,
headerMargin, headerMargin,
footerMargin, footerMargin,
leftMargin, leftMargin,

View file

@ -22,6 +22,7 @@ import { setLastDocumentLocation } from '@/lib/client/dexie';
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
import { getDocumentMetadata } from '@/lib/client/api/documents'; import { getDocumentMetadata } from '@/lib/client/api/documents';
import { ensureCachedDocument } from '@/lib/client/cache/documents'; import { ensureCachedDocument } from '@/lib/client/cache/documents';
import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { createRangeCfi } from '@/lib/client/epub'; import { createRangeCfi } from '@/lib/client/epub';
@ -29,6 +30,7 @@ import { useParams } from 'next/navigation';
import { useConfig } from './ConfigContext'; import { useConfig } from './ConfigContext';
import { CmpStr } from 'cmpstr'; import { CmpStr } from 'cmpstr';
import type { import type {
EpubRenderedLocationWalker,
TTSSentenceAlignment, TTSSentenceAlignment,
TTSAudiobookFormat, TTSAudiobookFormat,
TTSAudiobookChapter, TTSAudiobookChapter,
@ -44,6 +46,7 @@ interface EPUBContextType {
setCurrentDocument: (id: string) => Promise<void>; setCurrentDocument: (id: string) => Promise<void>;
clearCurrDoc: () => void; clearCurrDoc: () => void;
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
walkUpcomingRenderedLocations: EpubRenderedLocationWalker;
createFullAudioBook: ( createFullAudioBook: (
onProgress: (progress: number) => void, onProgress: (progress: number) => void,
signal?: AbortSignal, signal?: AbortSignal,
@ -291,6 +294,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
baseUrl, baseUrl,
ttsProvider, ttsProvider,
smartSentenceSplitting, smartSentenceSplitting,
epubTheme,
epubHighlightEnabled, epubHighlightEnabled,
} = useConfig(); } = useConfig();
// Current document state // Current document state
@ -310,6 +314,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// Track current highlight CFI for removal // Track current highlight CFI for removal
const currentHighlightCfi = useRef<string | null>(null); const currentHighlightCfi = useRef<string | null>(null);
const currentWordHighlightCfi = useRef<string | null>(null); const currentWordHighlightCfi = useRef<string | null>(null);
const renderedLocationCloneManagerRef = useRef<EpubRenderedLocationCloneManager>(
new EpubRenderedLocationCloneManager(),
);
useEffect(() => () => {
void renderedLocationCloneManagerRef.current.destroy();
}, []);
useEffect(() => {
renderedLocationCloneManagerRef.current.invalidate();
}, [currDocData]);
/** /**
* Clears all current document state and stops any active TTS * Clears all current document state and stops any active TTS
@ -324,6 +339,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
renditionRef.current = undefined; renditionRef.current = undefined;
locationRef.current = 1; locationRef.current = 1;
tocRef.current = []; tocRef.current = [];
renderedLocationCloneManagerRef.current.invalidate();
stop(); stop();
}, [setCurrDocPages, stop]); }, [setCurrDocPages, stop]);
@ -413,6 +429,28 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
* Extracts text content from the entire EPUB book * Extracts text content from the entire EPUB book
* @returns {Promise<string[]>} Array of text content from each section * @returns {Promise<string[]>} Array of text content from each section
*/ */
const loadSpineSection = useCallback(async (href: string) => {
const book = bookRef.current;
if (!book?.isOpen) return null;
const section = book.spine.get(href);
if (!section) return null;
const loaded = await Promise.resolve(section.load(book.load.bind(book)));
const doc = (() => {
if (!loaded) return null;
if (typeof Document !== 'undefined' && loaded instanceof Document) {
return loaded;
}
const element = loaded as unknown as Element;
if (element?.ownerDocument) {
return element.ownerDocument;
}
const sectionWithDocument = section as unknown as { document?: Document };
return sectionWithDocument.document ?? null;
})();
if (!doc) return null;
return { section, doc };
}, []);
const extractBookText = useCallback(async (): Promise<Array<{ text: string; href: string }>> => { const extractBookText = useCallback(async (): Promise<Array<{ text: string; href: string }>> => {
try { try {
if (!bookRef.current || !bookRef.current.isOpen) return [{ text: '', href: '' }]; if (!bookRef.current || !bookRef.current.isOpen) return [{ text: '', href: '' }];
@ -424,17 +462,19 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
spine.each((item: SpineItem) => { spine.each((item: SpineItem) => {
const url = item.href || ''; const url = item.href || '';
if (!url) return; if (!url) return;
//console.log('Extracting text from section:', item as SpineItem); const promise = loadSpineSection(url)
.then((loaded) => {
const promise = book.load(url) if (!loaded?.doc) return { text: '', href: url };
.then((section) => (section as Document)) const text = loaded.doc.body?.textContent || '';
.then((section) => ({ return { text, href: url };
text: section.body.textContent || '', })
href: url
}))
.catch((err) => { .catch((err) => {
console.error(`Error loading section ${url}:`, err); console.error(`Error loading section ${url}:`, err);
return { text: '', href: url }; return { text: '', href: url };
})
.finally(() => {
const section = book.spine.get(url);
section?.unload?.();
}); });
promises.push(promise); promises.push(promise);
@ -442,13 +482,50 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const textArray = await Promise.all(promises); const textArray = await Promise.all(promises);
const filteredArray = textArray.filter(item => item.text.trim() !== ''); const filteredArray = textArray.filter(item => item.text.trim() !== '');
console.log('Extracted entire EPUB text array:', filteredArray);
return filteredArray; return filteredArray;
} catch (error) { } catch (error) {
console.error('Error extracting EPUB text:', error); console.error('Error extracting EPUB text:', error);
return [{ text: '', href: '' }]; return [{ text: '', href: '' }];
} }
}, []); }, [loadSpineSection]);
const walkUpcomingRenderedLocations = useCallback<EpubRenderedLocationWalker>(async (startCfi, depth, signal) => {
if (!startCfi || depth <= 0 || signal.aborted) return [];
const visibleRendition = renditionRef.current;
if (!currDocData || !visibleRendition || typeof document === 'undefined') return [];
const visibleSettings = (visibleRendition as unknown as {
settings?: {
spread?: string;
};
manager?: { stage?: { container?: Element | null }; container?: Element | null };
}).settings;
const containerElement =
(visibleRendition as unknown as { manager?: { stage?: { container?: Element | null }; container?: Element | null } }).manager?.stage?.container
?? (visibleRendition as unknown as { manager?: { container?: Element | null } }).manager?.container
?? null;
const bounds = containerElement?.getBoundingClientRect?.();
const width = Math.max(320, Math.floor(bounds?.width || 900));
const height = Math.max(320, Math.floor(bounds?.height || 700));
const theme = epubTheme
? {
foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'),
base: getComputedStyle(document.documentElement).getPropertyValue('--base'),
}
: null;
return renderedLocationCloneManagerRef.current.walk({
data: currDocData,
startCfi,
depth,
signal,
width,
height,
spread: visibleSettings?.spread,
theme,
});
}, [currDocData, epubTheme]);
const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({ const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({
extractBookText, extractBookText,
@ -521,13 +598,55 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
renditionRef.current = rendition; renditionRef.current = rendition;
}, []); }, []);
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
const book = bookRef.current;
const rendition = renditionRef.current;
if (!book?.isOpen || !rendition) return false;
const guardNavigationPromise = (promiseLike: unknown): void => {
const promise = Promise.resolve(promiseLike);
void promise.catch((error) => {
console.warn(`EPUB rendition ${navigation} failed:`, error);
});
};
try {
if (navigation === 'display') {
if (!location) return false;
guardNavigationPromise(rendition.display(location));
return true;
}
if (navigation === 'next') {
guardNavigationPromise(rendition.next());
return true;
}
guardNavigationPromise(rendition.prev());
return true;
} catch (error) {
console.warn(`EPUB rendition ${navigation} failed:`, error);
return false;
}
}, []);
const handleLocationChanged = useCallback((location: string | number) => { const handleLocationChanged = useCallback((location: string | number) => {
// Handle directional navigation before first-location initialization so
// "prev"/"next" are not treated as raw CFI strings.
if ((location === 'next' || location === 'prev') && renditionRef.current) {
if (!isEPUBSetOnce.current) {
setIsEPUB(true);
isEPUBSetOnce.current = true;
}
shouldPauseRef.current = false;
safeRenditionNavigate(location === 'next' ? 'next' : 'prev');
return;
}
// Set the EPUB flag once the location changes // Set the EPUB flag once the location changes
if (!isEPUBSetOnce.current) { if (!isEPUBSetOnce.current) {
setIsEPUB(true); setIsEPUB(true);
isEPUBSetOnce.current = true; isEPUBSetOnce.current = true;
renditionRef.current?.display(location.toString()); safeRenditionNavigate('display', location.toString());
return; return;
} }
@ -538,7 +657,10 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
if (typeof location === 'string' && location !== 'next' && location !== 'prev' && renditionRef.current?.location) { if (typeof location === 'string' && location !== 'next' && location !== 'prev' && renditionRef.current?.location) {
const currentStartCfi = renditionRef.current.location?.start?.cfi; const currentStartCfi = renditionRef.current.location?.start?.cfi;
if (currentStartCfi && location !== currentStartCfi) { if (currentStartCfi && location !== currentStartCfi) {
renditionRef.current.display(location); // Programmatic cross-location jumps (segments sidebar / TTS navigation)
// should keep autoplay intent after the rendition finishes navigating.
shouldPauseRef.current = false;
safeRenditionNavigate('display', location);
return; return;
} }
} }
@ -546,18 +668,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// Handle special 'next' and 'prev' cases // Handle special 'next' and 'prev' cases
if (location === 'next' && renditionRef.current) { if (location === 'next' && renditionRef.current) {
shouldPauseRef.current = false; shouldPauseRef.current = false;
renditionRef.current.next(); safeRenditionNavigate('next');
return; return;
} }
if (location === 'prev' && renditionRef.current) { if (location === 'prev' && renditionRef.current) {
shouldPauseRef.current = false; shouldPauseRef.current = false;
renditionRef.current.prev(); safeRenditionNavigate('prev');
return; return;
} }
// Save the location to IndexedDB if not initial // Save the location to IndexedDB if not initial
if (id && locationRef.current !== 1) { if (id && locationRef.current !== 1) {
console.log('Saving location:', location);
setLastDocumentLocation(id as string, location.toString()); setLastDocumentLocation(id as string, location.toString());
if (authEnabled) { if (authEnabled) {
scheduleDocumentProgressSync({ scheduleDocumentProgressSync({
@ -575,7 +696,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current); extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current);
shouldPauseRef.current = true; shouldPauseRef.current = true;
} }
}, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled]); }, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled, safeRenditionNavigate]);
const clearWordHighlights = useCallback(() => { const clearWordHighlights = useCallback(() => {
if (!renditionRef.current) return; if (!renditionRef.current) return;
@ -622,9 +743,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const cfi = content.cfiFromRange(range); const cfi = content.cfiFromRange(range);
// Store CFI for removal // Store CFI for removal
currentHighlightCfi.current = cfi; currentHighlightCfi.current = cfi;
renditionRef.current.annotations.add('highlight', cfi, {}, (e: MouseEvent) => { renditionRef.current.annotations.add(
console.log('Highlight clicked', e); 'highlight',
}, '', { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' }); cfi,
{},
() => {},
'',
{ fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' },
);
// Clear the browser selection so it doesn't look like user selected text // Clear the browser selection so it doesn't look like user selected text
sel?.removeAllRanges(); sel?.removeAllRanges();
@ -914,6 +1040,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
currDocText, currDocText,
clearCurrDoc, clearCurrDoc,
extractPageText, extractPageText,
walkUpcomingRenderedLocations,
createFullAudioBook, createFullAudioBook,
regenerateChapter, regenerateChapter,
bookRef, bookRef,
@ -937,6 +1064,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
currDocText, currDocText,
clearCurrDoc, clearCurrDoc,
extractPageText, extractPageText,
walkUpcomingRenderedLocations,
createFullAudioBook, createFullAudioBook,
regenerateChapter, regenerateChapter,
handleLocationChanged, handleLocationChanged,

View file

@ -47,6 +47,7 @@ import type {
TTSAudiobookChapter, TTSAudiobookChapter,
} from '@/types/tts'; } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client'; import type { AudiobookGenerationSettings } from '@/types/client';
import { clampSegmentPreloadDepth } from '@/types/config';
/** /**
* Interface defining all available methods and properties in the PDF context * Interface defining all available methods and properties in the PDF context
@ -126,6 +127,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
baseUrl, baseUrl,
ttsProvider, ttsProvider,
smartSentenceSplitting, smartSentenceSplitting,
segmentPreloadDepthPages,
ttsSegmentMaxBlockLength,
} = useConfig(); } = useConfig();
// Current document state // Current document state
@ -144,7 +147,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
right: rightMargin, right: rightMargin,
}, },
smartSentenceSplitting, smartSentenceSplitting,
}), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting]); maxBlockLength: ttsSegmentMaxBlockLength,
}), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]);
const pageTextCacheRef = useRef<Map<number, string>>(new Map()); const pageTextCacheRef = useRef<Map<number, string>>(new Map());
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber); const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
@ -173,7 +177,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
* @param {PDFDocumentProxy} pdf - The loaded PDF document proxy object * @param {PDFDocumentProxy} pdf - The loaded PDF document proxy object
*/ */
const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => { const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => {
console.log('Document loaded:', pdf.numPages);
pdfDocGenerationRef.current += 1; pdfDocGenerationRef.current += 1;
pdfDocumentRef.current = pdf; pdfDocumentRef.current = pdf;
setCurrDocPages(pdf.numPages); setCurrDocPages(pdf.numPages);
@ -239,12 +242,27 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const totalPages = currDocPages ?? currentPdf.numPages; const totalPages = currDocPages ?? currentPdf.numPages;
const prevPageNumber = currDocPageNumber > 1 ? currDocPageNumber - 1 : undefined; const prevPageNumber = currDocPageNumber > 1 ? currDocPageNumber - 1 : undefined;
const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined; const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined;
const preloadDepth = clampSegmentPreloadDepth(segmentPreloadDepthPages);
const upcomingPageNumbers: number[] = [];
for (let offset = 1; offset <= preloadDepth; offset += 1) {
const pageNum = currDocPageNumber + offset;
if (pageNum > totalPages) break;
upcomingPageNumbers.push(pageNum);
}
const [text, prevText, nextText] = await Promise.all([ const [text, prevText, ...upcomingTexts] = await Promise.all([
getPageText(currDocPageNumber), getPageText(currDocPageNumber),
prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve<string | undefined>(undefined), prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve<string | undefined>(undefined),
nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve<string | undefined>(undefined), ...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)),
]); ]);
const nextText = upcomingTexts[0];
const additionalUpcoming = upcomingPageNumbers
.slice(1)
.map((pageNum, idx) => ({
location: pageNum,
text: upcomingTexts[idx + 1] || '',
}))
.filter((item) => item.text.trim().length > 0);
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
return; return;
@ -286,6 +304,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
previousText: prevText, previousText: prevText,
nextLocation: nextPageNumber, nextLocation: nextPageNumber,
nextText: nextText, nextText: nextText,
upcomingLocations: additionalUpcoming,
}); });
} }
} catch (error) { } catch (error) {
@ -303,6 +322,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
footerMargin, footerMargin,
leftMargin, leftMargin,
rightMargin, rightMargin,
segmentPreloadDepthPages,
]); ]);
/** /**
@ -482,6 +502,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const clamped = Math.min(Math.max(location, 1), totalPages); const clamped = Math.min(Math.max(location, 1), totalPages);
setCurrDocPage(clamped); setCurrDocPage(clamped);
}); });
return () => {
registerVisualPageChangeHandler(null);
};
}, [registerVisualPageChangeHandler, currDocPages, pdfDocument]); }, [registerVisualPageChangeHandler, currDocPages, pdfDocument]);
// Context value memoization // Context value memoization

File diff suppressed because it is too large Load diff

View file

@ -121,22 +121,28 @@ export const getThemeStyles = (epubTheme: boolean): IReactReaderStyle => {
export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefined) => { export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefined) => {
const updateTheme = useCallback(() => { const updateTheme = useCallback(() => {
if (!epubTheme || !rendition) return; if (!epubTheme || !rendition) return;
const maybeBook = (rendition as unknown as { book?: { isOpen?: boolean } }).book;
if (!maybeBook?.isOpen) return;
const colors = { const colors = {
foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'), foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'),
base: getComputedStyle(document.documentElement).getPropertyValue('--base'), base: getComputedStyle(document.documentElement).getPropertyValue('--base'),
}; };
// Register theme rules instead of using override try {
rendition.themes.registerRules('theme-light', { // Register theme rules instead of using override
'body': { rendition.themes.registerRules('theme-light', {
'color': colors.foreground, 'body': {
'background-color': colors.base 'color': colors.foreground,
} 'background-color': colors.base
}); }
});
// Select the theme to apply it // Select the theme to apply it
rendition.themes.select('theme-light'); rendition.themes.select('theme-light');
} catch (error) {
console.warn('Failed to apply EPUB theme rules:', error);
}
}, [epubTheme, rendition]); }, [epubTheme, rendition]);
// Watch for theme changes // Watch for theme changes
@ -165,5 +171,23 @@ export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefine
updateTheme(); updateTheme();
}, [epubTheme, rendition, updateTheme]); }, [epubTheme, rendition, updateTheme]);
// Ensure theme is applied once the rendition has fully rendered/opened.
useEffect(() => {
if (!epubTheme || !rendition) return;
const emitter = rendition as unknown as {
on?: (event: string, callback: () => void) => void;
off?: (event: string, callback: () => void) => void;
};
if (!emitter.on) return;
const handleRendered = () => {
updateTheme();
};
emitter.on('rendered', handleRendered);
return () => {
emitter.off?.('rendered', handleRendered);
};
}, [epubTheme, rendition, updateTheme]);
return { updateTheme }; return { updateTheme };
}; };

View file

@ -40,7 +40,11 @@ export const withRetry = async <T>(
// Do not retry on explicit cancellation/abort errors - surface them // Do not retry on explicit cancellation/abort errors - surface them
// immediately so callers can stop work quickly when the user cancels. // immediately so callers can stop work quickly when the user cancels.
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) { if (
lastError.name === 'AbortError'
|| lastError.message.toLowerCase().includes('cancelled')
|| lastError.message.toLowerCase().includes('aborted')
) {
break; break;
} }

View file

@ -13,12 +13,14 @@ interface PdfAudiobookAdapterOptions {
right: number; right: number;
}; };
smartSentenceSplitting: boolean; smartSentenceSplitting: boolean;
maxBlockLength?: number;
} }
async function extractPreparedPdfChapters({ async function extractPreparedPdfChapters({
pdfDocument, pdfDocument,
margins, margins,
smartSentenceSplitting, smartSentenceSplitting,
maxBlockLength,
}: PdfAudiobookAdapterOptions): Promise<PreparedAudiobookChapter[]> { }: PdfAudiobookAdapterOptions): Promise<PreparedAudiobookChapter[]> {
if (!pdfDocument) { if (!pdfDocument) {
throw new Error('No PDF document loaded'); throw new Error('No PDF document loaded');
@ -35,7 +37,7 @@ async function extractPreparedPdfChapters({
chapters.push({ chapters.push({
index: chapters.length, index: chapters.length,
title: `Page ${chapters.length + 1}`, title: `Page ${chapters.length + 1}`,
text: smartSentenceSplitting ? normalizeTextForTts(trimmedText) : trimmedText, text: smartSentenceSplitting ? normalizeTextForTts(trimmedText, { maxBlockLength }) : trimmedText,
}); });
} }

View file

@ -0,0 +1,302 @@
import type { Book, Rendition } from 'epubjs';
import { createRangeCfi } from '@/lib/client/epub';
export interface RenderedLocationWalkItem {
location: string;
text: string;
}
export interface RenderedLocationWalkRequest {
data: ArrayBuffer;
startCfi: string;
depth: number;
signal: AbortSignal;
width: number;
height: number;
spread?: string;
theme?: {
foreground: string;
base: string;
} | null;
}
type Session = {
key: string;
host: HTMLDivElement;
book: Book;
rendition: Rendition;
};
type EpubFactory = (input: ArrayBuffer, options?: { openAs?: string }) => Book;
const MAX_BACKOFF_MS = 3_000;
const BACKOFF_BASE_MS = 250;
const isAbortError = (error: unknown): boolean =>
error instanceof Error && error.name === 'AbortError';
const normalizeCfiKey = (cfi: string): string =>
cfi
.trim()
.replace(/\[;s=[ab]\]/gi, '')
.replace(/\s+/g, '');
const createAbortPromise = (signal: AbortSignal): Promise<never> =>
new Promise<never>((_, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
});
function resolveEpubFactory(moduleValue: unknown): EpubFactory {
const candidate = (moduleValue as { default?: unknown; ePub?: unknown }).default
?? (moduleValue as { ePub?: unknown }).ePub
?? moduleValue;
if (typeof candidate !== 'function') {
throw new Error('Failed to resolve epubjs factory function');
}
return candidate as EpubFactory;
}
export class EpubRenderedLocationCloneManager {
private queue: Promise<void> = Promise.resolve();
private activeSession: Session | null = null;
private modulePromise: Promise<EpubFactory> | null = null;
private failures = 0;
private backoffUntilMs = 0;
private generation = 0;
private docIds = new WeakMap<ArrayBuffer, number>();
private nextDocId = 1;
private docIdFor(buffer: ArrayBuffer): number {
const existing = this.docIds.get(buffer);
if (existing) return existing;
const next = this.nextDocId;
this.nextDocId += 1;
this.docIds.set(buffer, next);
return next;
}
private buildSessionKey(request: RenderedLocationWalkRequest): string {
const themeSig = request.theme
? `${request.theme.foreground}|${request.theme.base}`
: 'none';
return [
`doc:${this.docIdFor(request.data)}`,
`w:${Math.floor(request.width)}`,
`h:${Math.floor(request.height)}`,
`s:${request.spread || 'auto'}`,
`t:${themeSig}`,
].join('|');
}
private async getFactory(): Promise<EpubFactory> {
if (!this.modulePromise) {
this.modulePromise = import('epubjs').then((moduleValue) => resolveEpubFactory(moduleValue));
}
return this.modulePromise;
}
private noteFailure(): void {
this.failures += 1;
const delay = Math.min(MAX_BACKOFF_MS, BACKOFF_BASE_MS * (2 ** Math.max(0, this.failures - 1)));
this.backoffUntilMs = Date.now() + delay;
}
private noteSuccess(): void {
this.failures = 0;
this.backoffUntilMs = 0;
}
invalidate(): void {
this.generation += 1;
this.noteSuccess();
void this.enqueue(async () => {
await this.resetSession();
});
}
async destroy(): Promise<void> {
this.generation += 1;
this.noteSuccess();
await this.enqueue(async () => {
await this.resetSession();
});
}
private async enqueue<T>(fn: () => Promise<T>): Promise<T> {
const next = this.queue.then(fn, fn);
this.queue = next.then(
() => undefined,
() => undefined,
);
return next;
}
private async resetSession(): Promise<void> {
const current = this.activeSession;
this.activeSession = null;
if (!current) return;
try {
current.rendition.destroy();
} catch {}
try {
current.book.destroy();
} catch {}
try {
current.host.remove();
} catch {}
}
private async ensureSession(
request: RenderedLocationWalkRequest,
abortPromise: Promise<never>,
): Promise<Session> {
const key = this.buildSessionKey(request);
if (this.activeSession?.key === key) {
return this.activeSession;
}
await this.resetSession();
const host = document.createElement('div');
host.setAttribute('aria-hidden', 'true');
host.style.position = 'fixed';
host.style.left = '-99999px';
host.style.top = '0';
host.style.width = `${Math.max(320, Math.floor(request.width))}px`;
host.style.height = `${Math.max(320, Math.floor(request.height))}px`;
host.style.opacity = '0';
host.style.pointerEvents = 'none';
host.style.overflow = 'hidden';
document.body.appendChild(host);
let book: Book | null = null;
try {
const createBook = await Promise.race([this.getFactory(), abortPromise]);
const clonedData = request.data.slice(0);
// ArrayBuffer input must use binary auto-detection; forcing openAs:'epub'
// treats the payload like a URL/path and can break packaging metadata.
book = createBook(clonedData);
await Promise.race([book.ready, abortPromise]);
const rendition = book.renderTo(host, {
width: Math.max(320, Math.floor(request.width)),
height: Math.max(320, Math.floor(request.height)),
manager: 'default',
flow: 'paginated',
spread: request.spread,
allowScriptedContent: false,
});
if (request.theme) {
try {
rendition.themes.registerRules('openreader-preload-theme', {
body: {
color: request.theme.foreground,
'background-color': request.theme.base,
},
});
rendition.themes.select('openreader-preload-theme');
} catch (error) {
console.warn('Failed applying preload EPUB theme rules:', error);
}
}
const session: Session = {
key,
host,
book,
rendition,
};
this.activeSession = session;
return session;
} catch (error) {
try {
book?.destroy();
} catch {}
host.remove();
throw error;
}
}
private async walkWithSession(
session: Session,
request: RenderedLocationWalkRequest,
abortPromise: Promise<never>,
): Promise<RenderedLocationWalkItem[]> {
const results: RenderedLocationWalkItem[] = [];
const seen = new Set<string>();
const anchor = normalizeCfiKey(request.startCfi);
await Promise.race([session.rendition.display(request.startCfi), abortPromise]);
let attempts = 0;
while (results.length < request.depth && attempts < request.depth + 3) {
attempts += 1;
if (request.signal.aborted) break;
try {
await Promise.race([session.rendition.next(), abortPromise]);
} catch {
break;
}
const location = (session.rendition.location
?? await Promise.resolve(session.rendition.currentLocation())) as {
start?: { cfi?: string };
end?: { cfi?: string };
} | undefined;
const start = location?.start?.cfi || '';
const end = location?.end?.cfi || '';
if (normalizeCfiKey(start) === anchor) {
// Some renditions report the original displayed location on the first
// next() tick. Skip it so depth preloads always target upcoming pages.
continue;
}
if (!start || !end || seen.has(start)) continue;
seen.add(start);
const rangeCfi = createRangeCfi(start, end);
const range = await Promise.race([session.book.getRange(rangeCfi), abortPromise]);
const text = range?.toString()?.trim() || '';
if (!text) continue;
results.push({
location: start,
text,
});
}
return results;
}
async walk(request: RenderedLocationWalkRequest): Promise<RenderedLocationWalkItem[]> {
if (!request.startCfi || request.depth <= 0 || request.signal.aborted || typeof document === 'undefined') {
return [];
}
if (Date.now() < this.backoffUntilMs) {
return [];
}
const generationAtStart = this.generation;
const abortPromise = createAbortPromise(request.signal);
return this.enqueue(async () => {
if (generationAtStart !== this.generation || request.signal.aborted) return [];
try {
const session = await this.ensureSession(request, abortPromise);
const items = await this.walkWithSession(session, request, abortPromise);
if (generationAtStart !== this.generation) return [];
this.noteSuccess();
return items;
} catch (error) {
if (!isAbortError(error)) {
this.noteFailure();
await this.resetSession();
console.warn('Failed walking upcoming rendered EPUB locations:', error);
}
return [];
}
});
}
}

View file

@ -100,7 +100,10 @@ export async function deleteTtsSegmentAudioObjects(keys: string[]): Promise<numb
}, },
}), }),
); );
deleted += deleteRes.Deleted?.length ?? 0; // With Quiet=true, many S3-compatible providers omit Deleted entries even on success.
// Count attempted keys minus explicit per-key errors to avoid false partial-delete reports.
const errored = deleteRes.Errors?.length ?? 0;
deleted += Math.max(0, chunk.length - errored);
} }
return deleted; return deleted;

View file

@ -0,0 +1,77 @@
import { locatorGroupKey } from '@/lib/shared/tts-locator';
import type {
TTSSegmentLocator,
TTSSegmentVariant,
} from '@/types/client';
export { locatorGroupKey };
export const DEFAULT_PAGE_SIZE = 150;
export const MIN_PAGE_SIZE = 25;
export const MAX_PAGE_SIZE = 500;
function statusRank(status: TTSSegmentVariant['status']): number {
if (status === 'completed') return 3;
if (status === 'pending') return 2;
return 1;
}
export function dedupeManifestVariants(variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>): TTSSegmentVariant[] {
const byKey = new Map<string, TTSSegmentVariant>();
for (const { dedupeKey, variant } of variants) {
const existing = byKey.get(dedupeKey);
if (!existing) {
byKey.set(dedupeKey, variant);
continue;
}
const existingRank = statusRank(existing.status);
const nextRank = statusRank(variant.status);
const existingUpdatedAt = existing.updatedAt ?? 0;
const nextUpdatedAt = variant.updatedAt ?? 0;
if (nextRank > existingRank || (nextRank === existingRank && nextUpdatedAt >= existingUpdatedAt)) {
byKey.set(dedupeKey, variant);
}
}
return Array.from(byKey.values());
}
export function compareManifestSegments(
a: { locator: TTSSegmentLocator | null; segmentIndex: number; groupKey: string },
b: { locator: TTSSegmentLocator | null; segmentIndex: number; groupKey: string },
): number {
const aPage = typeof a.locator?.page === 'number' ? a.locator.page : Number.MAX_SAFE_INTEGER;
const bPage = typeof b.locator?.page === 'number' ? b.locator.page : Number.MAX_SAFE_INTEGER;
if (aPage !== bPage) return aPage - bPage;
const aLoc = a.locator?.location || '';
const bLoc = b.locator?.location || '';
const byLocation = aLoc.localeCompare(bLoc);
if (byLocation !== 0) return byLocation;
if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex;
return a.groupKey.localeCompare(b.groupKey);
}
export function decodeManifestCursor(cursor: string | null): string | null {
if (!cursor) return null;
try {
const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
if (!decoded) return null;
// Reject malformed input that Node's base64url decoder may parse into gibberish.
const normalizedInput = cursor.replace(/=+$/, '');
if (encodeManifestCursor(decoded) !== normalizedInput) {
return null;
}
return decoded;
} catch {
return null;
}
}
export function encodeManifestCursor(value: string): string {
return Buffer.from(value, 'utf8').toString('base64url');
}
export function parseManifestPageSize(value: string | null): number {
const parsed = Number.parseInt(value || '', 10);
if (!Number.isFinite(parsed)) return DEFAULT_PAGE_SIZE;
return Math.max(MIN_PAGE_SIZE, Math.min(MAX_PAGE_SIZE, parsed));
}

View file

@ -75,6 +75,22 @@ export function locatorFingerprint(locator: TTSSegmentLocator | null): string {
return createHash('sha256').update(stableStringify(locator)).digest('hex'); return createHash('sha256').update(stableStringify(locator)).digest('hex');
} }
export function canonicalLocatorJson(locator: TTSSegmentLocator | null | undefined): string | null {
if (!locator) return null;
return stableStringify(locator);
}
export function canonicalizeLocatorJsonString(json: string | null | undefined): string | null {
if (!json) return null;
try {
const parsed = JSON.parse(json);
if (parsed === null || typeof parsed !== 'object') return null;
return stableStringify(parsed);
} catch {
return null;
}
}
export function buildTtsSegmentId(input: { export function buildTtsSegmentId(input: {
documentId: string; documentId: string;
documentVersion: number; documentVersion: number;

View file

@ -8,6 +8,17 @@
import nlp from 'compromise'; import nlp from 'compromise';
export const MAX_BLOCK_LENGTH = 450; export const MAX_BLOCK_LENGTH = 450;
const MIN_BLOCK_LENGTH = 50;
export interface TtsSplitOptions {
maxBlockLength?: number;
}
function resolveMaxBlockLength(options?: TtsSplitOptions): number {
const candidate = Number(options?.maxBlockLength ?? MAX_BLOCK_LENGTH);
if (!Number.isFinite(candidate)) return MAX_BLOCK_LENGTH;
return Math.max(MIN_BLOCK_LENGTH, Math.floor(candidate));
}
const splitOversizedText = (text: string, maxLen: number): string[] => { const splitOversizedText = (text: string, maxLen: number): string[] => {
const normalized = text.replace(/\s+/g, ' ').trim(); const normalized = text.replace(/\s+/g, ' ').trim();
@ -138,7 +149,8 @@ export const preprocessSentenceForAudio = (text: string): string => {
* @param {string} text - The text to split into sentences * @param {string} text - The text to split into sentences
* @returns {string[]} Array of sentence blocks * @returns {string[]} Array of sentence blocks
*/ */
export const splitTextToTtsBlocks = (text: string): string[] => { export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): string[] => {
const maxBlockLength = resolveMaxBlockLength(options);
// Treat double-newlines as paragraph boundaries; single newlines are usually // Treat double-newlines as paragraph boundaries; single newlines are usually
// just PDF line wrapping and should not force sentence/block boundaries. // just PDF line wrapping and should not force sentence/block boundaries.
const paragraphs = text.split(/\n{2,}/); const paragraphs = text.split(/\n{2,}/);
@ -160,10 +172,10 @@ export const splitTextToTtsBlocks = (text: string): string[] => {
for (const sentence of mergedSentences) { for (const sentence of mergedSentences) {
const trimmedSentence = sentence.trim(); const trimmedSentence = sentence.trim();
const sentenceParts = splitOversizedText(trimmedSentence, MAX_BLOCK_LENGTH); const sentenceParts = splitOversizedText(trimmedSentence, maxBlockLength);
for (const sentencePart of sentenceParts) { for (const sentencePart of sentenceParts) {
if (currentBlock && (currentBlock.length + sentencePart.length + 1) > MAX_BLOCK_LENGTH) { if (currentBlock && (currentBlock.length + sentencePart.length + 1) > maxBlockLength) {
blocks.push(currentBlock.trim()); blocks.push(currentBlock.trim());
currentBlock = sentencePart; currentBlock = sentencePart;
} else { } else {
@ -186,7 +198,8 @@ export const splitTextToTtsBlocks = (text: string): string[] => {
* EPUB block splitting used where we want the produced sentences * EPUB block splitting used where we want the produced sentences
* to closely match the original DOM text (for exact-match highlighting). * to closely match the original DOM text (for exact-match highlighting).
*/ */
export const splitTextToTtsBlocksEPUB = (text: string): string[] => { export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions): string[] => {
const maxBlockLength = resolveMaxBlockLength(options);
const paragraphs = text.split(/\n+/); const paragraphs = text.split(/\n+/);
const blocks: string[] = []; const blocks: string[] = [];
@ -204,12 +217,12 @@ export const splitTextToTtsBlocksEPUB = (text: string): string[] => {
for (const sentence of mergedSentences) { for (const sentence of mergedSentences) {
const trimmedSentence = sentence.trim(); const trimmedSentence = sentence.trim();
const sentenceParts = const sentenceParts =
trimmedSentence.length > MAX_BLOCK_LENGTH trimmedSentence.length > maxBlockLength
? splitOversizedText(trimmedSentence, MAX_BLOCK_LENGTH) ? splitOversizedText(trimmedSentence, maxBlockLength)
: [trimmedSentence]; : [trimmedSentence];
for (const sentencePart of sentenceParts) { for (const sentencePart of sentenceParts) {
if (currentBlock && (currentBlock.length + sentencePart.length + 1) > MAX_BLOCK_LENGTH) { if (currentBlock && (currentBlock.length + sentencePart.length + 1) > maxBlockLength) {
blocks.push(currentBlock.trim()); blocks.push(currentBlock.trim());
currentBlock = sentencePart; currentBlock = sentencePart;
} else { } else {
@ -235,8 +248,8 @@ export const splitTextToTtsBlocksEPUB = (text: string): string[] => {
* @param {string} text - The text to process * @param {string} text - The text to process
* @returns {string} Normalized text * @returns {string} Normalized text
*/ */
export const normalizeTextForTts = (text: string): string => export const normalizeTextForTts = (text: string, options?: TtsSplitOptions): string =>
splitTextToTtsBlocks(text).join(' '); splitTextToTtsBlocks(text, options).join(' ');
// Helper functions to merge quoted dialogue across sentences // Helper functions to merge quoted dialogue across sentences
const countDoubleQuotes = (s: string): number => { const countDoubleQuotes = (s: string): number => {

View file

@ -0,0 +1,18 @@
import type { TTSSegmentLocator } from '@/types/client';
export function normalizeEpubLocationToken(location: string): string {
return location
.trim()
.replace(/\[;s=[ab]\]/gi, '')
.replace(/\s+/g, '');
}
export function locatorGroupKey(locator: TTSSegmentLocator | null): string {
if (!locator) return 'none';
const page = typeof locator.page === 'number' && Number.isFinite(locator.page)
? String(Math.floor(locator.page))
: '';
const location = typeof locator.location === 'string' ? locator.location : '';
const readerType = locator.readerType || '';
return `p:${page}|l:${location}|r:${readerType}`;
}

View file

@ -150,4 +150,6 @@ export interface TTSSegmentRow {
export interface TTSSegmentsManifestResponse { export interface TTSSegmentsManifestResponse {
documentId: string; documentId: string;
segments: TTSSegmentRow[]; segments: TTSSegmentRow[];
nextCursor: string | null;
hasMore: boolean;
} }

View file

@ -8,6 +8,29 @@ export type ViewType = 'single' | 'dual' | 'scroll';
export type SavedVoices = Record<string, string>; export type SavedVoices = Record<string, string>;
export const SEGMENT_PRELOAD_DEPTH_MIN = 1;
export const SEGMENT_PRELOAD_DEPTH_MAX = 5;
export const SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN = 1;
export const SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX = 10;
export const TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN = 150;
export const TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX = 1200;
export const TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP = 25;
export function clampSegmentPreloadDepth(value: number | undefined | null): number {
const candidate = Math.floor(Number(value) || SEGMENT_PRELOAD_DEPTH_MIN);
return Math.max(SEGMENT_PRELOAD_DEPTH_MIN, Math.min(SEGMENT_PRELOAD_DEPTH_MAX, candidate));
}
export function clampSegmentPreloadSentenceLookahead(value: number | undefined | null): number {
const candidate = Math.floor(Number(value) || SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN);
return Math.max(SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN, Math.min(SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX, candidate));
}
export function clampTtsSegmentMaxBlockLength(value: number | undefined | null): number {
const candidate = Math.floor(Number(value) || TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN);
return Math.max(TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN, Math.min(TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX, candidate));
}
export interface AppConfigValues { export interface AppConfigValues {
apiKey: string; apiKey: string;
baseUrl: string; baseUrl: string;
@ -26,6 +49,9 @@ export interface AppConfigValues {
ttsInstructions: string; ttsInstructions: string;
savedVoices: SavedVoices; savedVoices: SavedVoices;
smartSentenceSplitting: boolean; smartSentenceSplitting: boolean;
segmentPreloadDepthPages: number;
segmentPreloadSentenceLookahead: number;
ttsSegmentMaxBlockLength: number;
pdfHighlightEnabled: boolean; pdfHighlightEnabled: boolean;
pdfWordHighlightEnabled: boolean; pdfWordHighlightEnabled: boolean;
epubHighlightEnabled: boolean; epubHighlightEnabled: boolean;
@ -54,6 +80,9 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
ttsInstructions: '', ttsInstructions: '',
savedVoices: {}, savedVoices: {},
smartSentenceSplitting: true, smartSentenceSplitting: true,
segmentPreloadDepthPages: 1,
segmentPreloadSentenceLookahead: 3,
ttsSegmentMaxBlockLength: 450,
pdfHighlightEnabled: true, pdfHighlightEnabled: true,
pdfWordHighlightEnabled: wordHighlightEnabledByDefault, pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
epubHighlightEnabled: true, epubHighlightEnabled: true,

View file

@ -58,6 +58,12 @@ export interface TTSSentenceAlignment {
words: TTSSentenceWord[]; words: TTSSentenceWord[];
} }
export type EpubRenderedLocationWalker = (
startCfi: string,
depth: number,
signal: AbortSignal,
) => Promise<Array<{ location: string; text: string }>>;
// Supported output formats for generated audiobooks // Supported output formats for generated audiobooks
export type TTSAudiobookFormat = 'mp3' | 'm4b'; export type TTSAudiobookFormat = 'mp3' | 'm4b';

View file

@ -8,6 +8,9 @@ export const SYNCED_PREFERENCE_KEYS = [
'skipBlank', 'skipBlank',
'epubTheme', 'epubTheme',
'smartSentenceSplitting', 'smartSentenceSplitting',
'segmentPreloadDepthPages',
'segmentPreloadSentenceLookahead',
'ttsSegmentMaxBlockLength',
'headerMargin', 'headerMargin',
'footerMargin', 'footerMargin',
'leftMargin', 'leftMargin',
@ -36,4 +39,3 @@ export interface DocumentProgressRecord {
clientUpdatedAtMs: number; clientUpdatedAtMs: number;
updatedAtMs: number; updatedAtMs: number;
} }

View file

@ -73,9 +73,8 @@ test.describe('PDF view modes and Navigator', () => {
// Open document settings (page-level settings) // Open document settings (page-level settings)
await page.getByRole('button', { name: 'Open settings' }).click(); await page.getByRole('button', { name: 'Open settings' }).click();
// The mode Listbox initially shows "Single Page" by default; switch to "Two Pages" // Page mode now uses pill-style radios; switch from Single Page to Two Pages
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click(); await page.getByRole('radiogroup', { name: 'Page mode' }).getByRole('radio', { name: 'Two Pages' }).click();
await page.getByRole('option', { name: 'Two Pages' }).click();
await page.getByRole('button', { name: 'Close' }).click(); await page.getByRole('button', { name: 'Close' }).click();
// Expect dual-page rendering (sample.pdf has >= 2 pages) // Expect dual-page rendering (sample.pdf has >= 2 pages)
@ -84,8 +83,7 @@ test.describe('PDF view modes and Navigator', () => {
// Switch to Continuous Scroll // Switch to Continuous Scroll
await page.getByRole('button', { name: 'Open settings' }).click(); await page.getByRole('button', { name: 'Open settings' }).click();
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click(); await page.getByRole('radiogroup', { name: 'Page mode' }).getByRole('radio', { name: 'Continuous Scroll' }).click();
await page.getByRole('option', { name: 'Continuous Scroll' }).click();
await page.getByRole('button', { name: 'Close' }).click(); await page.getByRole('button', { name: 'Close' }).click();
// Expect continuous scroll renders at least as many pages as dual mode // Expect continuous scroll renders at least as many pages as dual mode

View file

@ -111,6 +111,14 @@ test.describe('splitTextToTtsBlocks (PDF-oriented)', () => {
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH); expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
}); });
test('supports configurable max block length', () => {
const input = Array(600).fill('word').join(' ');
const result = splitTextToTtsBlocks(input, { maxBlockLength: 220 });
expect(result.length).toBeGreaterThan(1);
expectNormalizedBlocks(result, 220 * 2);
});
test('prefers sentence punctuation when chunking long PDF-like text', () => { test('prefers sentence punctuation when chunking long PDF-like text', () => {
const sentences = Array.from( const sentences = Array.from(
{ length: 80 }, { length: 80 },
@ -175,6 +183,14 @@ test.describe('splitTextToTtsBlocksEPUB (highlight-friendly)', () => {
expect(result.length).toBeGreaterThan(1); expect(result.length).toBeGreaterThan(1);
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH); expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
}); });
test('supports configurable max block length for EPUB mode', () => {
const input = Array(600).fill('word').join(' ');
const result = splitTextToTtsBlocksEPUB(input, { maxBlockLength: 180 });
expect(result.length).toBeGreaterThan(1);
expectNormalizedBlocks(result, 180);
});
}); });
test.describe('normalizeTextForTts', () => { test.describe('normalizeTextForTts', () => {

View file

@ -0,0 +1,114 @@
import { expect, test } from '@playwright/test';
import {
compareManifestSegments,
decodeManifestCursor,
dedupeManifestVariants,
encodeManifestCursor,
parseManifestPageSize,
} from '../../src/lib/server/tts/segments-manifest';
test.describe('tts segments manifest helpers', () => {
test('dedupe prefers completed variant over newer pending for same settings key', () => {
const variants = dedupeManifestVariants([
{
dedupeKey: 'settings:abc',
variant: {
segmentId: 'old-completed',
settings: null,
audioPresignUrl: '/audio/old',
audioFallbackUrl: '/audio/old/fallback',
durationMs: 1100,
status: 'completed',
textLength: 12,
alignmentWordCount: 2,
audioKey: 'old',
updatedAt: 100,
},
},
{
dedupeKey: 'settings:abc',
variant: {
segmentId: 'new-pending',
settings: null,
audioPresignUrl: null,
audioFallbackUrl: null,
durationMs: null,
status: 'pending',
textLength: 15,
alignmentWordCount: 0,
audioKey: null,
updatedAt: 200,
},
},
]);
expect(variants).toHaveLength(1);
expect(variants[0].segmentId).toBe('old-completed');
expect(variants[0].status).toBe('completed');
});
test('dedupe uses status rank as tiebreaker when updatedAt is equal', () => {
const variants = dedupeManifestVariants([
{
dedupeKey: 'settings:abc',
variant: {
segmentId: 'pending',
settings: null,
audioPresignUrl: null,
audioFallbackUrl: null,
durationMs: null,
status: 'pending',
textLength: 12,
alignmentWordCount: 0,
audioKey: null,
updatedAt: 123,
},
},
{
dedupeKey: 'settings:abc',
variant: {
segmentId: 'completed',
settings: null,
audioPresignUrl: '/audio/new',
audioFallbackUrl: '/audio/new/fallback',
durationMs: 1200,
status: 'completed',
textLength: 12,
alignmentWordCount: 2,
audioKey: 'new',
updatedAt: 123,
},
},
]);
expect(variants).toHaveLength(1);
expect(variants[0].segmentId).toBe('completed');
expect(variants[0].status).toBe('completed');
});
test('sorts segments deterministically by page, location, and segment index', () => {
const rows = [
{ groupKey: 'c', segmentIndex: 4, locator: { page: 2, location: 'z' } },
{ groupKey: 'a', segmentIndex: 1, locator: { page: 1, location: 'b' } },
{ groupKey: 'b', segmentIndex: 0, locator: { page: 1, location: 'a' } },
{ groupKey: 'd', segmentIndex: 2, locator: { page: 1, location: 'a' } },
];
const sorted = rows.sort(compareManifestSegments);
expect(sorted.map((row) => row.groupKey)).toEqual(['b', 'd', 'a', 'c']);
});
test('encodes and decodes cursors', () => {
const raw = '3|p:2|l:epubcfi(/6/2)|r:epub';
const encoded = encodeManifestCursor(raw);
expect(decodeManifestCursor(encoded)).toBe(raw);
expect(decodeManifestCursor('not-base64')).toBeNull();
});
test('clamps page size bounds', () => {
expect(parseManifestPageSize(null)).toBe(150);
expect(parseManifestPageSize('10')).toBe(25);
expect(parseManifestPageSize('900')).toBe(500);
expect(parseManifestPageSize('200')).toBe(200);
});
});