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:
parent
3862d0b0ad
commit
265e1cb56a
29 changed files with 2475 additions and 602 deletions
|
|
@ -48,12 +48,16 @@ export async function POST(request: NextRequest) {
|
|||
const audioKeys = rows
|
||||
.map((row) => row.audioKey)
|
||||
.filter((key): key is string => Boolean(key));
|
||||
const uniqueAudioKeys = Array.from(new Set(audioKeys));
|
||||
|
||||
let deletedAudioObjects = 0;
|
||||
let warning: string | undefined;
|
||||
if (audioKeys.length > 0) {
|
||||
if (uniqueAudioKeys.length > 0) {
|
||||
try {
|
||||
deletedAudioObjects = await deleteTtsSegmentAudioObjects(audioKeys);
|
||||
deletedAudioObjects = await deleteTtsSegmentAudioObjects(uniqueAudioKeys);
|
||||
if (deletedAudioObjects < uniqueAudioKeys.length) {
|
||||
warning = `Deleted ${deletedAudioObjects} of ${uniqueAudioKeys.length} audio objects.`;
|
||||
}
|
||||
} catch (error) {
|
||||
warning = error instanceof Error ? error.message : 'Failed deleting some audio objects';
|
||||
console.warn('Failed clearing some TTS segment audio objects:', {
|
||||
|
|
@ -67,6 +71,7 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({
|
||||
documentId: parsed.documentId,
|
||||
deletedSegments: rows.length,
|
||||
requestedAudioObjects: uniqueAudioKeys.length,
|
||||
deletedAudioObjects,
|
||||
...(warning ? { warning } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
buildTtsSegmentSettingsHash,
|
||||
buildTtsSegmentSettingsJson,
|
||||
buildTtsSegmentTextHash,
|
||||
canonicalLocatorJson,
|
||||
canonicalizeLocatorJsonString,
|
||||
locatorFingerprint,
|
||||
normalizeLocator,
|
||||
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;
|
||||
documentId: string;
|
||||
documentVersion: number;
|
||||
segmentIndex: number;
|
||||
activeLocatorJson: string | null;
|
||||
activeSegmentId: string;
|
||||
activeSettingsHash: string;
|
||||
}): Promise<void> {
|
||||
|
|
@ -138,6 +151,7 @@ async function cleanupStaleErroredVariants(input: {
|
|||
segmentId: ttsSegments.segmentId,
|
||||
settingsHash: ttsSegments.settingsHash,
|
||||
audioKey: ttsSegments.audioKey,
|
||||
locatorJson: ttsSegments.locatorJson,
|
||||
})
|
||||
.from(ttsSegments)
|
||||
.where(and(
|
||||
|
|
@ -150,9 +164,14 @@ async function cleanupStaleErroredVariants(input: {
|
|||
segmentId: string;
|
||||
settingsHash: string;
|
||||
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);
|
||||
|
||||
if (staleIds.length === 0) return;
|
||||
|
|
@ -164,9 +183,9 @@ async function cleanupStaleErroredVariants(input: {
|
|||
inArray(ttsSegments.segmentId, staleIds),
|
||||
));
|
||||
|
||||
const staleAudioKeys = staleRowsForSettings
|
||||
const staleAudioKeys = Array.from(new Set(staleRowsForSettings
|
||||
.map((row) => row.audioKey)
|
||||
.filter((key): key is string => Boolean(key));
|
||||
.filter((key): key is string => Boolean(key))));
|
||||
if (staleAudioKeys.length > 0) {
|
||||
try {
|
||||
await deleteTtsSegmentAudioObjects(staleAudioKeys);
|
||||
|
|
@ -263,11 +282,12 @@ export async function POST(request: NextRequest) {
|
|||
const existing = existingById.get(segment.segmentId);
|
||||
|
||||
if (existing?.status === 'completed' && existing.audioKey) {
|
||||
await cleanupStaleErroredVariants({
|
||||
await cleanupStaleCanonicalVariants({
|
||||
userId: scope.storageUserId,
|
||||
documentId: parsed.documentId,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
activeLocatorJson: existing.locatorJson,
|
||||
activeSegmentId: segment.segmentId,
|
||||
activeSettingsHash: settingsHash,
|
||||
});
|
||||
|
|
@ -333,6 +353,7 @@ export async function POST(request: NextRequest) {
|
|||
segmentId: segment.segmentId,
|
||||
});
|
||||
|
||||
const segmentLocatorJson = canonicalLocatorJson(segment.locator);
|
||||
await db
|
||||
.insert(ttsSegments)
|
||||
.values({
|
||||
|
|
@ -342,7 +363,7 @@ export async function POST(request: NextRequest) {
|
|||
readerType: scope.readerType,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null,
|
||||
locatorJson: segmentLocatorJson,
|
||||
settingsHash,
|
||||
settingsJson,
|
||||
textHash: segment.textHash,
|
||||
|
|
@ -360,7 +381,7 @@ export async function POST(request: NextRequest) {
|
|||
readerType: scope.readerType,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null,
|
||||
locatorJson: segmentLocatorJson,
|
||||
settingsHash,
|
||||
settingsJson,
|
||||
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)));
|
||||
|
||||
await cleanupStaleErroredVariants({
|
||||
await cleanupStaleCanonicalVariants({
|
||||
userId: scope.storageUserId,
|
||||
documentId: parsed.documentId,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
activeLocatorJson: canonicalLocatorJson(segment.locator),
|
||||
activeSegmentId: segment.segmentId,
|
||||
activeSettingsHash: settingsHash,
|
||||
});
|
||||
|
|
@ -492,11 +514,12 @@ export async function POST(request: NextRequest) {
|
|||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to generate segment';
|
||||
const aborted = isAbortLikeError(error);
|
||||
await db
|
||||
.update(ttsSegments)
|
||||
.set({
|
||||
status: 'error',
|
||||
error: message,
|
||||
status: aborted ? 'pending' : 'error',
|
||||
error: aborted ? null : message,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
|
||||
|
|
@ -509,7 +532,7 @@ export async function POST(request: NextRequest) {
|
|||
durationMs: 0,
|
||||
alignment: null,
|
||||
locator: segment.locator,
|
||||
status: 'error',
|
||||
status: aborted ? 'pending' : 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@ import { and, asc, eq } from 'drizzle-orm';
|
|||
import { db } from '@/db';
|
||||
import { ttsSegments } from '@/db/schema';
|
||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||
import {
|
||||
compareManifestSegments,
|
||||
decodeManifestCursor,
|
||||
dedupeManifestVariants,
|
||||
encodeManifestCursor,
|
||||
locatorGroupKey,
|
||||
parseManifestPageSize,
|
||||
} from '@/lib/server/tts/segments-manifest';
|
||||
import type {
|
||||
TTSSegmentLocator,
|
||||
TTSSegmentRow,
|
||||
|
|
@ -65,45 +73,17 @@ function buildSegmentAudioUrls(documentId: string, segmentId: string): {
|
|||
};
|
||||
}
|
||||
|
||||
function statusRank(status: TTSSegmentVariant['status']): number {
|
||||
if (status === 'completed') return 3;
|
||||
if (status === 'pending') return 2;
|
||||
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}`;
|
||||
function isAbortLikeMessage(message: string | null | undefined): boolean {
|
||||
if (!message) return false;
|
||||
return /abort/i.test(message);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const documentIdRaw = request.nextUrl.searchParams.get('documentId');
|
||||
const documentId = documentIdRaw?.trim().toLowerCase();
|
||||
const limit = parseManifestPageSize(request.nextUrl.searchParams.get('limit'));
|
||||
const cursor = decodeManifestCursor(request.nextUrl.searchParams.get('cursor'));
|
||||
if (!documentId) {
|
||||
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'
|
||||
? row.status
|
||||
: 'pending';
|
||||
const status: TTSSegmentVariant['status'] = row.status === 'completed'
|
||||
? 'completed'
|
||||
: row.status === 'error' && !isAbortLikeMessage(row.error)
|
||||
? 'error'
|
||||
: 'pending';
|
||||
|
||||
const audioUrls = row.status === 'completed' && row.audioKey
|
||||
? buildSegmentAudioUrls(documentId, row.segmentId)
|
||||
|
|
@ -196,22 +178,40 @@ export async function GET(request: NextRequest) {
|
|||
});
|
||||
}
|
||||
|
||||
const segments = Array.from(grouped.values())
|
||||
.map((segment) => ({
|
||||
const segments = Array.from(grouped.entries())
|
||||
.map(([groupKey, segment]) => ({
|
||||
groupKey,
|
||||
segmentIndex: segment.segmentIndex,
|
||||
locator: segment.locator,
|
||||
variants: dedupeVariants(segment.variants),
|
||||
variants: dedupeManifestVariants(segment.variants),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex;
|
||||
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 || '';
|
||||
return aLoc.localeCompare(bLoc);
|
||||
});
|
||||
const response: TTSSegmentsManifestResponse = { documentId, segments };
|
||||
.sort(compareManifestSegments);
|
||||
|
||||
let startIndex = 0;
|
||||
if (cursor) {
|
||||
const cursorIndex = segments.findIndex((segment) => segment.groupKey === cursor);
|
||||
if (cursorIndex < 0) {
|
||||
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 });
|
||||
}
|
||||
if (cursorIndex >= 0) startIndex = cursorIndex + 1;
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('Error listing TTS segments manifest:', error);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
|
|||
break;
|
||||
case 'voiceSpeed':
|
||||
case 'audioPlayerSpeed':
|
||||
case 'segmentPreloadDepthPages':
|
||||
case 'segmentPreloadSentenceLookahead':
|
||||
case 'ttsSegmentMaxBlockLength':
|
||||
case 'headerMargin':
|
||||
case 'footerMargin':
|
||||
case 'leftMargin':
|
||||
|
|
|
|||
|
|
@ -464,6 +464,7 @@ export function AudiobookExportModal({
|
|||
onClose={() => setIsOpen(false)}
|
||||
ariaLabel="Export audiobook"
|
||||
title="Export Audiobook"
|
||||
subtitle="Only leaving the document cancels generation."
|
||||
>
|
||||
{isLoadingExisting ? (
|
||||
<div className="flex justify-center py-8">
|
||||
|
|
@ -882,9 +883,7 @@ export function AudiobookExportModal({
|
|||
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted">
|
||||
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.
|
||||
Audiobook settings are fixed after generation. Chapters will appear here as they are ready.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
'use client';
|
||||
|
||||
import { Fragment, useState, useEffect } from 'react';
|
||||
import { Transition, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react';
|
||||
import { useState, useEffect, type ChangeEvent } from 'react';
|
||||
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
||||
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';
|
||||
|
||||
|
|
@ -14,6 +24,81 @@ const viewTypeTextMapping = [
|
|||
{ 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 }: {
|
||||
isOpen: boolean,
|
||||
setIsOpen: (isOpen: boolean) => void,
|
||||
|
|
@ -25,6 +110,9 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
skipBlank,
|
||||
epubTheme,
|
||||
smartSentenceSplitting,
|
||||
segmentPreloadDepthPages,
|
||||
segmentPreloadSentenceLookahead,
|
||||
ttsSegmentMaxBlockLength,
|
||||
headerMargin,
|
||||
footerMargin,
|
||||
leftMargin,
|
||||
|
|
@ -42,6 +130,15 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
right: rightMargin
|
||||
});
|
||||
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(() => {
|
||||
setLocalMargins({
|
||||
|
|
@ -52,297 +149,235 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
});
|
||||
}, [headerMargin, footerMargin, leftMargin, rightMargin]);
|
||||
|
||||
// Handler for slider change (updates local state only)
|
||||
const handleMarginChange = (margin: keyof typeof localMargins) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalMargins(prev => ({
|
||||
...prev,
|
||||
[margin]: Number(event.target.value)
|
||||
}));
|
||||
};
|
||||
useEffect(() => {
|
||||
setLocalPreloadDepth(segmentPreloadDepthPages);
|
||||
}, [segmentPreloadDepthPages]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSentenceLookahead(segmentPreloadSentenceLookahead);
|
||||
}, [segmentPreloadSentenceLookahead]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalMaxBlockLength(ttsSegmentMaxBlockLength);
|
||||
}, [ttsSegmentMaxBlockLength]);
|
||||
|
||||
// Handler for slider release
|
||||
const handleMarginChangeComplete = (margin: keyof typeof localMargins) => () => {
|
||||
const handleMarginChangeComplete = (margin: MarginKey) => () => {
|
||||
const value = localMargins[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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarginSliderChange = (margin: MarginKey) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalMargins((previous) => ({
|
||||
...previous,
|
||||
[margin]: Number(event.target.value),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ReaderSidebarShell
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
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">
|
||||
{!epub && !html && <div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Text extraction margins
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{/* 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>
|
||||
{!html && (
|
||||
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Playback Flow</h3>
|
||||
<p className="text-xs text-muted mt-0.5">Control segment generation and lookahead while audio is active.</p>
|
||||
</div>
|
||||
|
||||
{/* Footer Margin */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">Footer</span>
|
||||
<span className="text-xs font-bold">{Math.round(localMargins.footer * 100)}%</span>
|
||||
</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>
|
||||
<ToggleRow
|
||||
label="Skip blank pages"
|
||||
description="Automatically skip pages with no readable text."
|
||||
checked={skipBlank}
|
||||
onChange={(checked) => updateConfigKey('skipBlank', checked)}
|
||||
/>
|
||||
|
||||
{/* Left Margin */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">Left</span>
|
||||
<span className="text-xs font-bold">{Math.round(localMargins.left * 100)}%</span>
|
||||
</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>
|
||||
<ToggleRow
|
||||
label="Smart sentence splitting"
|
||||
description="Merge sentence fragments across page or section boundaries."
|
||||
checked={smartSentenceSplitting}
|
||||
onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
|
||||
/>
|
||||
|
||||
{/* Right Margin */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">Right</span>
|
||||
<span className="text-xs font-bold">{Math.round(localMargins.right * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="0.2"
|
||||
step="0.01"
|
||||
value={localMargins.right}
|
||||
onChange={handleMarginChange('right')}
|
||||
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 className="rounded-xl border border-offbase bg-background px-3 py-3 space-y-3 shadow-sm">
|
||||
<RangeSetting
|
||||
label="Segment preload depth"
|
||||
value={localPreloadDepth}
|
||||
min={SEGMENT_PRELOAD_DEPTH_MIN}
|
||||
max={SEGMENT_PRELOAD_DEPTH_MAX}
|
||||
step={1}
|
||||
description="How many upcoming pages or locations to queue in the background."
|
||||
formatter={(value) => String(value)}
|
||||
onChange={(value) => {
|
||||
const next = clampSegmentPreloadDepth(value);
|
||||
setLocalPreloadDepth(next);
|
||||
void updateConfigKey('segmentPreloadDepthPages', next);
|
||||
}}
|
||||
/>
|
||||
|
||||
</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">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipBlank}
|
||||
onChange={(e) => updateConfigKey('skipBlank', e.target.checked)}
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">Skip blank pages</span>
|
||||
</label>
|
||||
<p className="text-sm text-muted pl-6">
|
||||
Automatically skip pages with no text content
|
||||
</p>
|
||||
</div>}
|
||||
{!html && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={smartSentenceSplitting}
|
||||
onChange={(e) => updateConfigKey('smartSentenceSplitting', e.target.checked)}
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Smart sentence splitting
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-muted pl-6">
|
||||
Merge sentences across page or section breaks
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!epub && !html && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pdfHighlightEnabled}
|
||||
onChange={(e) => updateConfigKey('pdfHighlightEnabled', e.target.checked)}
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
|
||||
</label>
|
||||
<p className="text-sm text-muted pl-6">
|
||||
Visual text playback highlighting in the PDF viewer
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 pl-6">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
|
||||
disabled={!pdfHighlightEnabled || !canWordHighlight}
|
||||
onChange={(e) =>
|
||||
updateConfigKey('pdfWordHighlightEnabled', e.target.checked)
|
||||
}
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Word-by-word
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-muted pl-6">
|
||||
Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{epub && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={epubHighlightEnabled}
|
||||
onChange={(e) => updateConfigKey('epubHighlightEnabled', e.target.checked)}
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
|
||||
</label>
|
||||
<p className="text-sm text-muted pl-6">
|
||||
Visual text playback highlighting in the EPUB viewer
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 pl-6">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={epubWordHighlightEnabled && epubHighlightEnabled}
|
||||
disabled={!epubHighlightEnabled || !canWordHighlight}
|
||||
onChange={(e) =>
|
||||
updateConfigKey('epubWordHighlightEnabled', e.target.checked)
|
||||
}
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Word-by-word
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-muted pl-6">
|
||||
Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{epub && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={epubTheme}
|
||||
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)}
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">Use theme</span>
|
||||
</label>
|
||||
<p className="text-sm text-muted pl-6">
|
||||
Apply the current app theme to the EPUB viewer background and text colors
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<RangeSetting
|
||||
label="TTS segment max block length"
|
||||
value={localMaxBlockLength}
|
||||
min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN}
|
||||
max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX}
|
||||
step={TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP}
|
||||
description="Maximum character count used when chunking text into segment blocks."
|
||||
valueWidth="w-14"
|
||||
formatter={(value) => String(value)}
|
||||
onChange={(value) => {
|
||||
const next = clampTtsSegmentMaxBlockLength(value);
|
||||
setLocalMaxBlockLength(next);
|
||||
void updateConfigKey('ttsSegmentMaxBlockLength', next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!epub && !html && (
|
||||
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">PDF Layout & Extraction</h3>
|
||||
<p className="text-xs text-muted mt-0.5">Set viewer mode and trim page edges before extraction.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-foreground">Page mode</label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Page mode"
|
||||
className="grid grid-cols-3 gap-1 rounded-full border border-offbase bg-background p-1"
|
||||
>
|
||||
{viewTypeTextMapping.map((view) => {
|
||||
const active = selectedView.id === view.id;
|
||||
return (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => updateConfigKey('viewType', view.id as ViewType)}
|
||||
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 ${
|
||||
active
|
||||
? 'bg-accent text-background shadow-sm'
|
||||
: 'text-muted hover:bg-base hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{view.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedView.id === 'scroll' ? (
|
||||
<p className="text-xs text-warning">Continuous scroll may perform poorly for very large PDFs.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-offbase bg-background px-3 py-3 shadow-sm">
|
||||
<p className="text-xs font-medium text-foreground">Text extraction margins</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Exclude content near edges before sentence extraction.
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => (
|
||||
<div key={margin} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="capitalize text-foreground">{margin}</span>
|
||||
<span className="font-semibold text-foreground">{Math.round(localMargins[margin] * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="0.2"
|
||||
step="0.01"
|
||||
value={localMargins[margin]}
|
||||
onChange={handleMarginSliderChange(margin)}
|
||||
onMouseUp={handleMarginChangeComplete(margin)}
|
||||
onKeyUp={handleMarginChangeComplete(margin)}
|
||||
onTouchEnd={handleMarginChangeComplete(margin)}
|
||||
className={rangeInputClassName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!epub && !html && (
|
||||
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label="Highlight text during playback"
|
||||
description="Visual sentence-level playback highlighting in the PDF viewer."
|
||||
checked={pdfHighlightEnabled}
|
||||
onChange={(checked) => updateConfigKey('pdfHighlightEnabled', checked)}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Word-by-word highlighting"
|
||||
description={`Use whisper.cpp timing data to highlight words as speech progresses${!canWordHighlight ? ' (disabled by configuration)' : ''}.`}
|
||||
checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
|
||||
disabled={!pdfHighlightEnabled || !canWordHighlight}
|
||||
onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{epub && (
|
||||
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">EPUB Appearance</h3>
|
||||
<p className="text-xs text-muted mt-0.5">Apply app styling and playback highlighting in EPUB mode.</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label="Apply app theme"
|
||||
description="Use selected theme on EPUB documents. May require refresh."
|
||||
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>
|
||||
</ReaderSidebarShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface ReaderSidebarShellProps {
|
|||
onClose: () => void;
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
|
|
@ -22,6 +23,7 @@ export function ReaderSidebarShell({
|
|||
onClose,
|
||||
ariaLabel,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
headerActions,
|
||||
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="min-w-0">
|
||||
<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 className="flex items-center gap-1 shrink-0">
|
||||
{headerActions}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||
import { locatorGroupKey, normalizeEpubLocationToken } from '@/lib/shared/tts-locator';
|
||||
import type {
|
||||
TTSSegmentLocator,
|
||||
TTSSegmentRow,
|
||||
|
|
@ -23,9 +25,18 @@ interface SegmentsSidebarProps {
|
|||
type FetchState =
|
||||
| { kind: 'idle' }
|
||||
| { 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 };
|
||||
|
||||
const MANIFEST_PAGE_SIZE = 150;
|
||||
|
||||
function formatDuration(ms: number | null | undefined): string {
|
||||
if (!ms || !Number.isFinite(ms) || ms <= 0) return '—';
|
||||
const sec = ms / 1000;
|
||||
|
|
@ -83,6 +94,10 @@ function locatorMatchesCurrent(
|
|||
): boolean {
|
||||
if (!locator) return false;
|
||||
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);
|
||||
}
|
||||
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
|
||||
|
|
@ -91,11 +106,71 @@ function locatorMatchesCurrent(
|
|||
return false;
|
||||
}
|
||||
|
||||
function latestUpdatedAt(row: TTSSegmentRow): number {
|
||||
return row.variants.reduce((max, variant) => {
|
||||
const updated = typeof variant.updatedAt === 'number' ? variant.updatedAt : 0;
|
||||
return Math.max(max, updated);
|
||||
}, 0);
|
||||
function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string {
|
||||
if (!locator) return 'Unknown location';
|
||||
const parts: string[] = [];
|
||||
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
|
||||
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) {
|
||||
|
|
@ -105,14 +180,15 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
currDocPage,
|
||||
currDocPageNumber,
|
||||
isPlaying,
|
||||
stopAndPlayFromIndex,
|
||||
playFromSegment,
|
||||
} = useTTS();
|
||||
const { ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions, updateConfigKey } = useConfig();
|
||||
|
||||
const [state, setState] = useState<FetchState>({ kind: 'idle' });
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [clearError, setClearError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const didAutoScrollOnOpenRef = useRef(false);
|
||||
|
||||
const activeSettings = useMemo<TTSSegmentSettings>(() => ({
|
||||
ttsProvider,
|
||||
|
|
@ -122,15 +198,33 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
ttsInstructions: 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;
|
||||
const cursor = mode === 'append' ? cursorOverride : null;
|
||||
if (mode === 'append' && !cursor) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
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 {
|
||||
const params = new URLSearchParams({
|
||||
documentId,
|
||||
limit: String(MANIFEST_PAGE_SIZE),
|
||||
});
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const res = await fetch(
|
||||
`/api/tts/segments/manifest?documentId=${encodeURIComponent(documentId)}`,
|
||||
`/api/tts/segments/manifest?${params.toString()}`,
|
||||
{ signal: controller.signal, cache: 'no-store' },
|
||||
);
|
||||
if (!res.ok) {
|
||||
|
|
@ -138,33 +232,71 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
throw new Error(body || `Request failed (${res.status})`);
|
||||
}
|
||||
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) {
|
||||
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' });
|
||||
}
|
||||
}, [documentId]);
|
||||
|
||||
const handleClearCache = useCallback(async () => {
|
||||
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;
|
||||
|
||||
setIsClearing(true);
|
||||
setClearError(null);
|
||||
try {
|
||||
const res = await fetch('/api/tts/segments/clear', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(body || `Request failed (${res.status})`);
|
||||
throw new Error(payload?.error || `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) {
|
||||
setClearError(error instanceof Error ? error.message : 'Failed to clear segments cache');
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to clear segments cache');
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
|
|
@ -172,12 +304,31 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
void loadManifest();
|
||||
void loadManifest('reset');
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [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) => {
|
||||
if (!settings) return;
|
||||
await Promise.all([
|
||||
|
|
@ -189,61 +340,132 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
]);
|
||||
}, [updateConfigKey]);
|
||||
|
||||
const handleJump = useCallback((index: number) => {
|
||||
stopAndPlayFromIndex(index);
|
||||
}, [stopAndPlayFromIndex]);
|
||||
const handleRefresh = useCallback(() => {
|
||||
didAutoScrollOnOpenRef.current = false;
|
||||
void loadManifest('reset');
|
||||
}, [loadManifest]);
|
||||
|
||||
const segmentsByIndex = useMemo(() => {
|
||||
if (state.kind !== 'ready') return new Map<number, TTSSegmentRow>();
|
||||
const map = new Map<number, { row: TTSSegmentRow; score: number; updatedAt: number }>();
|
||||
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 handleJump = useCallback((index: number, locator: TTSSegmentLocator | null) => {
|
||||
playFromSegment(index, locator);
|
||||
}, [playFromSegment]);
|
||||
|
||||
const candidateUpdatedAt = latestUpdatedAt(row);
|
||||
const existing = map.get(row.segmentIndex);
|
||||
if (!existing) {
|
||||
map.set(row.segmentIndex, { row, score, updatedAt: candidateUpdatedAt });
|
||||
continue;
|
||||
const rowsToRender = useMemo(() => {
|
||||
if (state.kind !== 'ready') return [] as Array<{
|
||||
segmentIndex: number;
|
||||
sentenceText: string;
|
||||
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)) {
|
||||
map.set(row.segmentIndex, { row, score, updatedAt: candidateUpdatedAt });
|
||||
const variantsByIndex = new Map<number, TTSSegmentVariant[]>();
|
||||
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>();
|
||||
for (const [idx, entry] of map) selected.set(idx, entry.row);
|
||||
return selected;
|
||||
}, [state, currDocPage, currDocPageNumber]);
|
||||
const currentRows: TTSSegmentRow[] = sentences.map((_, segmentIndex) => ({
|
||||
segmentIndex,
|
||||
locator: inferredCurrentLocator,
|
||||
variants: variantsByIndex.get(segmentIndex) ?? [],
|
||||
}));
|
||||
|
||||
const indicesToRender = useMemo(() => {
|
||||
const indices = new Set<number>();
|
||||
for (let i = 0; i < sentences.length; i += 1) indices.add(i);
|
||||
if (state.kind === 'ready') {
|
||||
for (const row of state.data) {
|
||||
if (Number.isInteger(row.segmentIndex) && row.segmentIndex >= 0) {
|
||||
indices.add(row.segmentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
const mergedRows = [...currentRows, ...nonCurrentRows].sort(compareRows);
|
||||
return mergedRows.map((row) => {
|
||||
const isCurrentLocation = locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber);
|
||||
return {
|
||||
segmentIndex: row.segmentIndex,
|
||||
sentenceText: isCurrentLocation ? (sentences[row.segmentIndex] ?? '') : '',
|
||||
row,
|
||||
isCurrentLocation,
|
||||
groupKey: locatorGroupKey(row.locator),
|
||||
groupLabel: formatLocatorGroupLabel(row.locator),
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [state, currDocPage, currDocPageNumber, sentences]);
|
||||
|
||||
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;
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<button
|
||||
|
|
@ -258,7 +480,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadManifest()}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh segments"
|
||||
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"
|
||||
|
|
@ -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 (
|
||||
<ReaderSidebarShell
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
ariaLabel="TTS segments"
|
||||
title="Segments"
|
||||
subtitle="Click an index or sentence to jump. Click a voice label to switch the active voice."
|
||||
headerActions={headerActions}
|
||||
footer={footer}
|
||||
bodyClassName="flex-1 overflow-y-auto px-0 py-0"
|
||||
>
|
||||
<div className="px-4 py-2 border-b border-offbase">
|
||||
<div className="text-xs text-muted">
|
||||
{state.kind === 'ready' ? (
|
||||
<>
|
||||
{state.data.length} indexed
|
||||
{rowsToRender.length} indexed
|
||||
<span> · </span>
|
||||
{totalVariants} variants
|
||||
{state.hasMore ? (
|
||||
<>
|
||||
<span> · </span>
|
||||
more…
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : state.kind === 'loading' ? (
|
||||
<span>Loading…</span>
|
||||
|
|
@ -309,7 +524,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto">
|
||||
{state.kind === 'error' && (
|
||||
<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 && (
|
||||
<ul className="divide-y divide-offbase">
|
||||
{rowsToRender.map(({ segmentIndex, sentenceText, row }) => {
|
||||
const isCurrent = segmentIndex === currentSentenceIndex;
|
||||
const variants = row?.variants ?? [];
|
||||
{rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel }, rowIndex) => {
|
||||
const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null;
|
||||
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))
|
||||
?? variants[0]
|
||||
?? null;
|
||||
?? bestVariant;
|
||||
const status = activeVariant?.status ?? 'pending';
|
||||
const canJump = sentenceText.length > 0;
|
||||
const canJump = !!row.locator || sentenceText.length > 0;
|
||||
const playable = !!(activeVariant && activeVariant.audioPresignUrl);
|
||||
return (
|
||||
<li
|
||||
key={segmentIndex}
|
||||
key={`${groupKey}::${segmentIndex}`}
|
||||
data-active-segment={isCurrent ? 'true' : undefined}
|
||||
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 && (
|
||||
<span className="absolute inset-y-2 left-0 w-0.5 bg-accent rounded-r" aria-hidden />
|
||||
)}
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (canJump) handleJump(segmentIndex); }}
|
||||
onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }}
|
||||
disabled={!canJump}
|
||||
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'}
|
||||
|
|
@ -360,7 +596,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (canJump) handleJump(segmentIndex); }}
|
||||
onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }}
|
||||
disabled={!canJump}
|
||||
className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`}
|
||||
>
|
||||
|
|
@ -427,15 +663,16 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{row && (
|
||||
<SegmentMetadataPopover row={row} />
|
||||
)}
|
||||
<SegmentMetadataPopover row={row} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{state.kind === 'ready' && state.loadingMore && (
|
||||
<div className="px-4 py-3 text-xs text-muted">Loading more segments…</div>
|
||||
)}
|
||||
</div>
|
||||
</ReaderSidebarShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,10 +36,12 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
highlightPattern,
|
||||
clearHighlights,
|
||||
highlightWordIndex,
|
||||
clearWordHighlights
|
||||
clearWordHighlights,
|
||||
walkUpcomingRenderedLocations,
|
||||
} = useEPUB();
|
||||
const {
|
||||
registerLocationChangeHandler,
|
||||
registerEpubLocationWalker,
|
||||
pause,
|
||||
currentSentence,
|
||||
currentSentenceAlignment,
|
||||
|
|
@ -71,7 +73,12 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
// Register the location change handler
|
||||
useEffect(() => {
|
||||
registerLocationChangeHandler(handleLocationChanged);
|
||||
}, [registerLocationChangeHandler, handleLocationChanged]);
|
||||
registerEpubLocationWalker(walkUpcomingRenderedLocations);
|
||||
return () => {
|
||||
registerLocationChangeHandler(null);
|
||||
registerEpubLocationWalker(null);
|
||||
};
|
||||
}, [registerLocationChangeHandler, registerEpubLocationWalker, handleLocationChanged, walkUpcomingRenderedLocations]);
|
||||
|
||||
// Handle highlighting
|
||||
useEffect(() => {
|
||||
|
|
@ -132,7 +139,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
</button>
|
||||
<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"
|
||||
aria-label="Previous section"
|
||||
>
|
||||
|
|
@ -146,7 +153,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
)}
|
||||
<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"
|
||||
aria-label="Next section"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ interface ConfigContextType {
|
|||
skipBlank: boolean;
|
||||
epubTheme: boolean;
|
||||
smartSentenceSplitting: boolean;
|
||||
segmentPreloadDepthPages: number;
|
||||
segmentPreloadSentenceLookahead: number;
|
||||
ttsSegmentMaxBlockLength: number;
|
||||
headerMargin: number;
|
||||
footerMargin: number;
|
||||
leftMargin: number;
|
||||
|
|
@ -278,6 +281,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
ttsInstructions,
|
||||
savedVoices,
|
||||
smartSentenceSplitting,
|
||||
segmentPreloadDepthPages,
|
||||
segmentPreloadSentenceLookahead,
|
||||
ttsSegmentMaxBlockLength,
|
||||
pdfHighlightEnabled,
|
||||
pdfWordHighlightEnabled,
|
||||
epubHighlightEnabled,
|
||||
|
|
@ -354,6 +360,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
skipBlank,
|
||||
epubTheme,
|
||||
smartSentenceSplitting,
|
||||
segmentPreloadDepthPages,
|
||||
segmentPreloadSentenceLookahead,
|
||||
ttsSegmentMaxBlockLength,
|
||||
headerMargin,
|
||||
footerMargin,
|
||||
leftMargin,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { setLastDocumentLocation } from '@/lib/client/dexie';
|
|||
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
||||
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
||||
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
||||
import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
import { createRangeCfi } from '@/lib/client/epub';
|
||||
|
|
@ -29,6 +30,7 @@ import { useParams } from 'next/navigation';
|
|||
import { useConfig } from './ConfigContext';
|
||||
import { CmpStr } from 'cmpstr';
|
||||
import type {
|
||||
EpubRenderedLocationWalker,
|
||||
TTSSentenceAlignment,
|
||||
TTSAudiobookFormat,
|
||||
TTSAudiobookChapter,
|
||||
|
|
@ -44,6 +46,7 @@ interface EPUBContextType {
|
|||
setCurrentDocument: (id: string) => Promise<void>;
|
||||
clearCurrDoc: () => void;
|
||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||
walkUpcomingRenderedLocations: EpubRenderedLocationWalker;
|
||||
createFullAudioBook: (
|
||||
onProgress: (progress: number) => void,
|
||||
signal?: AbortSignal,
|
||||
|
|
@ -291,6 +294,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
baseUrl,
|
||||
ttsProvider,
|
||||
smartSentenceSplitting,
|
||||
epubTheme,
|
||||
epubHighlightEnabled,
|
||||
} = useConfig();
|
||||
// Current document state
|
||||
|
|
@ -310,6 +314,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
// Track current highlight CFI for removal
|
||||
const currentHighlightCfi = 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
|
||||
|
|
@ -324,6 +339,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
renditionRef.current = undefined;
|
||||
locationRef.current = 1;
|
||||
tocRef.current = [];
|
||||
renderedLocationCloneManagerRef.current.invalidate();
|
||||
stop();
|
||||
}, [setCurrDocPages, stop]);
|
||||
|
||||
|
|
@ -413,6 +429,28 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
* Extracts text content from the entire EPUB book
|
||||
* @returns {Promise<string[]>} Array of text content from each section
|
||||
*/
|
||||
const loadSpineSection = useCallback(async (href: string) => {
|
||||
const book = bookRef.current;
|
||||
if (!book?.isOpen) return null;
|
||||
const section = book.spine.get(href);
|
||||
if (!section) return null;
|
||||
const loaded = await Promise.resolve(section.load(book.load.bind(book)));
|
||||
const doc = (() => {
|
||||
if (!loaded) return null;
|
||||
if (typeof Document !== 'undefined' && loaded instanceof Document) {
|
||||
return loaded;
|
||||
}
|
||||
const element = loaded as unknown as Element;
|
||||
if (element?.ownerDocument) {
|
||||
return element.ownerDocument;
|
||||
}
|
||||
const sectionWithDocument = section as unknown as { document?: Document };
|
||||
return sectionWithDocument.document ?? null;
|
||||
})();
|
||||
if (!doc) return null;
|
||||
return { section, doc };
|
||||
}, []);
|
||||
|
||||
const extractBookText = useCallback(async (): Promise<Array<{ text: string; href: string }>> => {
|
||||
try {
|
||||
if (!bookRef.current || !bookRef.current.isOpen) return [{ text: '', href: '' }];
|
||||
|
|
@ -424,17 +462,19 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
spine.each((item: SpineItem) => {
|
||||
const url = item.href || '';
|
||||
if (!url) return;
|
||||
//console.log('Extracting text from section:', item as SpineItem);
|
||||
|
||||
const promise = book.load(url)
|
||||
.then((section) => (section as Document))
|
||||
.then((section) => ({
|
||||
text: section.body.textContent || '',
|
||||
href: url
|
||||
}))
|
||||
const promise = loadSpineSection(url)
|
||||
.then((loaded) => {
|
||||
if (!loaded?.doc) return { text: '', href: url };
|
||||
const text = loaded.doc.body?.textContent || '';
|
||||
return { text, href: url };
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Error loading section ${url}:`, err);
|
||||
return { text: '', href: url };
|
||||
})
|
||||
.finally(() => {
|
||||
const section = book.spine.get(url);
|
||||
section?.unload?.();
|
||||
});
|
||||
|
||||
promises.push(promise);
|
||||
|
|
@ -442,13 +482,50 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const textArray = await Promise.all(promises);
|
||||
const filteredArray = textArray.filter(item => item.text.trim() !== '');
|
||||
console.log('Extracted entire EPUB text array:', filteredArray);
|
||||
return filteredArray;
|
||||
} catch (error) {
|
||||
console.error('Error extracting EPUB text:', error);
|
||||
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({
|
||||
extractBookText,
|
||||
|
|
@ -521,13 +598,55 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
renditionRef.current = rendition;
|
||||
}, []);
|
||||
|
||||
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
|
||||
const book = bookRef.current;
|
||||
const rendition = renditionRef.current;
|
||||
if (!book?.isOpen || !rendition) return false;
|
||||
|
||||
const guardNavigationPromise = (promiseLike: unknown): void => {
|
||||
const promise = Promise.resolve(promiseLike);
|
||||
void promise.catch((error) => {
|
||||
console.warn(`EPUB rendition ${navigation} failed:`, error);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
if (navigation === 'display') {
|
||||
if (!location) return false;
|
||||
guardNavigationPromise(rendition.display(location));
|
||||
return true;
|
||||
}
|
||||
if (navigation === 'next') {
|
||||
guardNavigationPromise(rendition.next());
|
||||
return true;
|
||||
}
|
||||
guardNavigationPromise(rendition.prev());
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(`EPUB rendition ${navigation} failed:`, error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLocationChanged = useCallback((location: string | number) => {
|
||||
// Handle directional navigation before first-location initialization so
|
||||
// "prev"/"next" are not treated as raw CFI strings.
|
||||
if ((location === 'next' || location === 'prev') && renditionRef.current) {
|
||||
if (!isEPUBSetOnce.current) {
|
||||
setIsEPUB(true);
|
||||
isEPUBSetOnce.current = true;
|
||||
}
|
||||
shouldPauseRef.current = false;
|
||||
safeRenditionNavigate(location === 'next' ? 'next' : 'prev');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the EPUB flag once the location changes
|
||||
if (!isEPUBSetOnce.current) {
|
||||
setIsEPUB(true);
|
||||
isEPUBSetOnce.current = true;
|
||||
|
||||
renditionRef.current?.display(location.toString());
|
||||
safeRenditionNavigate('display', location.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -538,7 +657,10 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
if (typeof location === 'string' && location !== 'next' && location !== 'prev' && renditionRef.current?.location) {
|
||||
const currentStartCfi = renditionRef.current.location?.start?.cfi;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -546,18 +668,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
// Handle special 'next' and 'prev' cases
|
||||
if (location === 'next' && renditionRef.current) {
|
||||
shouldPauseRef.current = false;
|
||||
renditionRef.current.next();
|
||||
safeRenditionNavigate('next');
|
||||
return;
|
||||
}
|
||||
if (location === 'prev' && renditionRef.current) {
|
||||
shouldPauseRef.current = false;
|
||||
renditionRef.current.prev();
|
||||
safeRenditionNavigate('prev');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the location to IndexedDB if not initial
|
||||
if (id && locationRef.current !== 1) {
|
||||
console.log('Saving location:', location);
|
||||
setLastDocumentLocation(id as string, location.toString());
|
||||
if (authEnabled) {
|
||||
scheduleDocumentProgressSync({
|
||||
|
|
@ -575,7 +696,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current);
|
||||
shouldPauseRef.current = true;
|
||||
}
|
||||
}, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled]);
|
||||
}, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled, safeRenditionNavigate]);
|
||||
|
||||
const clearWordHighlights = useCallback(() => {
|
||||
if (!renditionRef.current) return;
|
||||
|
|
@ -622,9 +743,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const cfi = content.cfiFromRange(range);
|
||||
// Store CFI for removal
|
||||
currentHighlightCfi.current = cfi;
|
||||
renditionRef.current.annotations.add('highlight', cfi, {}, (e: MouseEvent) => {
|
||||
console.log('Highlight clicked', e);
|
||||
}, '', { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' });
|
||||
renditionRef.current.annotations.add(
|
||||
'highlight',
|
||||
cfi,
|
||||
{},
|
||||
() => {},
|
||||
'',
|
||||
{ fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' },
|
||||
);
|
||||
|
||||
// Clear the browser selection so it doesn't look like user selected text
|
||||
sel?.removeAllRanges();
|
||||
|
|
@ -914,6 +1040,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
currDocText,
|
||||
clearCurrDoc,
|
||||
extractPageText,
|
||||
walkUpcomingRenderedLocations,
|
||||
createFullAudioBook,
|
||||
regenerateChapter,
|
||||
bookRef,
|
||||
|
|
@ -937,6 +1064,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
currDocText,
|
||||
clearCurrDoc,
|
||||
extractPageText,
|
||||
walkUpcomingRenderedLocations,
|
||||
createFullAudioBook,
|
||||
regenerateChapter,
|
||||
handleLocationChanged,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import type {
|
|||
TTSAudiobookChapter,
|
||||
} from '@/types/tts';
|
||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
import { clampSegmentPreloadDepth } from '@/types/config';
|
||||
|
||||
/**
|
||||
* Interface defining all available methods and properties in the PDF context
|
||||
|
|
@ -126,6 +127,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
baseUrl,
|
||||
ttsProvider,
|
||||
smartSentenceSplitting,
|
||||
segmentPreloadDepthPages,
|
||||
ttsSegmentMaxBlockLength,
|
||||
} = useConfig();
|
||||
|
||||
// Current document state
|
||||
|
|
@ -144,7 +147,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
right: rightMargin,
|
||||
},
|
||||
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 [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
||||
|
||||
|
|
@ -173,7 +177,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
* @param {PDFDocumentProxy} pdf - The loaded PDF document proxy object
|
||||
*/
|
||||
const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => {
|
||||
console.log('Document loaded:', pdf.numPages);
|
||||
pdfDocGenerationRef.current += 1;
|
||||
pdfDocumentRef.current = pdf;
|
||||
setCurrDocPages(pdf.numPages);
|
||||
|
|
@ -239,12 +242,27 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const totalPages = currDocPages ?? currentPdf.numPages;
|
||||
const prevPageNumber = currDocPageNumber > 1 ? 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),
|
||||
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) {
|
||||
return;
|
||||
|
|
@ -286,6 +304,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
previousText: prevText,
|
||||
nextLocation: nextPageNumber,
|
||||
nextText: nextText,
|
||||
upcomingLocations: additionalUpcoming,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -303,6 +322,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
footerMargin,
|
||||
leftMargin,
|
||||
rightMargin,
|
||||
segmentPreloadDepthPages,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -482,6 +502,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const clamped = Math.min(Math.max(location, 1), totalPages);
|
||||
setCurrDocPage(clamped);
|
||||
});
|
||||
return () => {
|
||||
registerVisualPageChangeHandler(null);
|
||||
};
|
||||
}, [registerVisualPageChangeHandler, currDocPages, pdfDocument]);
|
||||
|
||||
// Context value memoization
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -121,22 +121,28 @@ export const getThemeStyles = (epubTheme: boolean): IReactReaderStyle => {
|
|||
export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefined) => {
|
||||
const updateTheme = useCallback(() => {
|
||||
if (!epubTheme || !rendition) return;
|
||||
const maybeBook = (rendition as unknown as { book?: { isOpen?: boolean } }).book;
|
||||
if (!maybeBook?.isOpen) return;
|
||||
|
||||
const colors = {
|
||||
foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'),
|
||||
base: getComputedStyle(document.documentElement).getPropertyValue('--base'),
|
||||
};
|
||||
|
||||
// Register theme rules instead of using override
|
||||
rendition.themes.registerRules('theme-light', {
|
||||
'body': {
|
||||
'color': colors.foreground,
|
||||
'background-color': colors.base
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Register theme rules instead of using override
|
||||
rendition.themes.registerRules('theme-light', {
|
||||
'body': {
|
||||
'color': colors.foreground,
|
||||
'background-color': colors.base
|
||||
}
|
||||
});
|
||||
|
||||
// Select the theme to apply it
|
||||
rendition.themes.select('theme-light');
|
||||
// Select the theme to apply it
|
||||
rendition.themes.select('theme-light');
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply EPUB theme rules:', error);
|
||||
}
|
||||
}, [epubTheme, rendition]);
|
||||
|
||||
// Watch for theme changes
|
||||
|
|
@ -165,5 +171,23 @@ export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefine
|
|||
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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ export const withRetry = async <T>(
|
|||
|
||||
// Do not retry on explicit cancellation/abort errors - surface them
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ interface PdfAudiobookAdapterOptions {
|
|||
right: number;
|
||||
};
|
||||
smartSentenceSplitting: boolean;
|
||||
maxBlockLength?: number;
|
||||
}
|
||||
|
||||
async function extractPreparedPdfChapters({
|
||||
pdfDocument,
|
||||
margins,
|
||||
smartSentenceSplitting,
|
||||
maxBlockLength,
|
||||
}: PdfAudiobookAdapterOptions): Promise<PreparedAudiobookChapter[]> {
|
||||
if (!pdfDocument) {
|
||||
throw new Error('No PDF document loaded');
|
||||
|
|
@ -35,7 +37,7 @@ async function extractPreparedPdfChapters({
|
|||
chapters.push({
|
||||
index: chapters.length,
|
||||
title: `Page ${chapters.length + 1}`,
|
||||
text: smartSentenceSplitting ? normalizeTextForTts(trimmedText) : trimmedText,
|
||||
text: smartSentenceSplitting ? normalizeTextForTts(trimmedText, { maxBlockLength }) : trimmedText,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
302
src/lib/client/epub/rendered-location-walker.ts
Normal file
302
src/lib/client/epub/rendered-location-walker.ts
Normal 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 [];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
77
src/lib/server/tts/segments-manifest.ts
Normal file
77
src/lib/server/tts/segments-manifest.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -75,6 +75,22 @@ export function locatorFingerprint(locator: TTSSegmentLocator | null): string {
|
|||
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: {
|
||||
documentId: string;
|
||||
documentVersion: number;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@
|
|||
import nlp from 'compromise';
|
||||
|
||||
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 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
|
||||
* @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
|
||||
// just PDF line wrapping and should not force sentence/block boundaries.
|
||||
const paragraphs = text.split(/\n{2,}/);
|
||||
|
|
@ -160,10 +172,10 @@ export const splitTextToTtsBlocks = (text: string): string[] => {
|
|||
|
||||
for (const sentence of mergedSentences) {
|
||||
const trimmedSentence = sentence.trim();
|
||||
const sentenceParts = splitOversizedText(trimmedSentence, MAX_BLOCK_LENGTH);
|
||||
const sentenceParts = splitOversizedText(trimmedSentence, maxBlockLength);
|
||||
|
||||
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());
|
||||
currentBlock = sentencePart;
|
||||
} else {
|
||||
|
|
@ -186,7 +198,8 @@ export const splitTextToTtsBlocks = (text: string): string[] => {
|
|||
* EPUB block splitting used where we want the produced sentences
|
||||
* 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 blocks: string[] = [];
|
||||
|
||||
|
|
@ -204,12 +217,12 @@ export const splitTextToTtsBlocksEPUB = (text: string): string[] => {
|
|||
for (const sentence of mergedSentences) {
|
||||
const trimmedSentence = sentence.trim();
|
||||
const sentenceParts =
|
||||
trimmedSentence.length > MAX_BLOCK_LENGTH
|
||||
? splitOversizedText(trimmedSentence, MAX_BLOCK_LENGTH)
|
||||
trimmedSentence.length > maxBlockLength
|
||||
? splitOversizedText(trimmedSentence, maxBlockLength)
|
||||
: [trimmedSentence];
|
||||
|
||||
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());
|
||||
currentBlock = sentencePart;
|
||||
} else {
|
||||
|
|
@ -235,8 +248,8 @@ export const splitTextToTtsBlocksEPUB = (text: string): string[] => {
|
|||
* @param {string} text - The text to process
|
||||
* @returns {string} Normalized text
|
||||
*/
|
||||
export const normalizeTextForTts = (text: string): string =>
|
||||
splitTextToTtsBlocks(text).join(' ');
|
||||
export const normalizeTextForTts = (text: string, options?: TtsSplitOptions): string =>
|
||||
splitTextToTtsBlocks(text, options).join(' ');
|
||||
|
||||
// Helper functions to merge quoted dialogue across sentences
|
||||
const countDoubleQuotes = (s: string): number => {
|
||||
|
|
|
|||
18
src/lib/shared/tts-locator.ts
Normal file
18
src/lib/shared/tts-locator.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -150,4 +150,6 @@ export interface TTSSegmentRow {
|
|||
export interface TTSSegmentsManifestResponse {
|
||||
documentId: string;
|
||||
segments: TTSSegmentRow[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,29 @@ export type ViewType = 'single' | 'dual' | 'scroll';
|
|||
|
||||
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 {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
|
|
@ -26,6 +49,9 @@ export interface AppConfigValues {
|
|||
ttsInstructions: string;
|
||||
savedVoices: SavedVoices;
|
||||
smartSentenceSplitting: boolean;
|
||||
segmentPreloadDepthPages: number;
|
||||
segmentPreloadSentenceLookahead: number;
|
||||
ttsSegmentMaxBlockLength: number;
|
||||
pdfHighlightEnabled: boolean;
|
||||
pdfWordHighlightEnabled: boolean;
|
||||
epubHighlightEnabled: boolean;
|
||||
|
|
@ -54,6 +80,9 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
|||
ttsInstructions: '',
|
||||
savedVoices: {},
|
||||
smartSentenceSplitting: true,
|
||||
segmentPreloadDepthPages: 1,
|
||||
segmentPreloadSentenceLookahead: 3,
|
||||
ttsSegmentMaxBlockLength: 450,
|
||||
pdfHighlightEnabled: true,
|
||||
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
|
||||
epubHighlightEnabled: true,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ export interface TTSSentenceAlignment {
|
|||
words: TTSSentenceWord[];
|
||||
}
|
||||
|
||||
export type EpubRenderedLocationWalker = (
|
||||
startCfi: string,
|
||||
depth: number,
|
||||
signal: AbortSignal,
|
||||
) => Promise<Array<{ location: string; text: string }>>;
|
||||
|
||||
// Supported output formats for generated audiobooks
|
||||
export type TTSAudiobookFormat = 'mp3' | 'm4b';
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ export const SYNCED_PREFERENCE_KEYS = [
|
|||
'skipBlank',
|
||||
'epubTheme',
|
||||
'smartSentenceSplitting',
|
||||
'segmentPreloadDepthPages',
|
||||
'segmentPreloadSentenceLookahead',
|
||||
'ttsSegmentMaxBlockLength',
|
||||
'headerMargin',
|
||||
'footerMargin',
|
||||
'leftMargin',
|
||||
|
|
@ -36,4 +39,3 @@ export interface DocumentProgressRecord {
|
|||
clientUpdatedAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,9 +73,8 @@ test.describe('PDF view modes and Navigator', () => {
|
|||
// Open document settings (page-level settings)
|
||||
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||
|
||||
// The mode Listbox initially shows "Single Page" by default; switch to "Two Pages"
|
||||
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click();
|
||||
await page.getByRole('option', { name: 'Two Pages' }).click();
|
||||
// Page mode now uses pill-style radios; switch from Single Page to Two Pages
|
||||
await page.getByRole('radiogroup', { name: 'Page mode' }).getByRole('radio', { name: 'Two Pages' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Expect dual-page rendering (sample.pdf has >= 2 pages)
|
||||
|
|
@ -84,8 +83,7 @@ test.describe('PDF view modes and Navigator', () => {
|
|||
|
||||
// Switch to Continuous Scroll
|
||||
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click();
|
||||
await page.getByRole('option', { name: 'Continuous Scroll' }).click();
|
||||
await page.getByRole('radiogroup', { name: 'Page mode' }).getByRole('radio', { name: 'Continuous Scroll' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Expect continuous scroll renders at least as many pages as dual mode
|
||||
|
|
|
|||
|
|
@ -111,6 +111,14 @@ test.describe('splitTextToTtsBlocks (PDF-oriented)', () => {
|
|||
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', () => {
|
||||
const sentences = Array.from(
|
||||
{ length: 80 },
|
||||
|
|
@ -175,6 +183,14 @@ test.describe('splitTextToTtsBlocksEPUB (highlight-friendly)', () => {
|
|||
expect(result.length).toBeGreaterThan(1);
|
||||
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', () => {
|
||||
|
|
|
|||
114
tests/unit/tts-segments-manifest.spec.ts
Normal file
114
tests/unit/tts-segments-manifest.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue