diff --git a/src/app/api/tts/segments/clear/route.ts b/src/app/api/tts/segments/clear/route.ts index 41d7e34..2f6fdea 100644 --- a/src/app/api/tts/segments/clear/route.ts +++ b/src/app/api/tts/segments/clear/route.ts @@ -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 } : {}), }); diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index bab9a18..948693a 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -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 { @@ -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', }); } } diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index c123e6d..fcaf0ca 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -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(); - 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); diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 6841e82..5e23286 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -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': diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index 7060a47..ab874fe 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -464,6 +464,7 @@ export function AudiobookExportModal({ onClose={() => setIsOpen(false)} ariaLabel="Export audiobook" title="Export Audiobook" + subtitle="Only leaving the document cancels generation." > {isLoadingExisting ? (
@@ -882,9 +883,7 @@ export function AudiobookExportModal({ {chapters.length === 0 && !isGenerating && !isLoadingExisting && (

- Audiobook settings are fixed after generation. Chapters will appear here as they are ready. -

- 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.

)} diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index ed721b2..faf6cb1 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -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 ( +
+ +
+ onChange(Number(event.target.value))} + className={`flex-1 ${rangeInputClassName}`} + /> + {formatter(value)} +
+

{description}

+
+ ); +} + +type ToggleRowProps = { + label: string; + description: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +}; + +function ToggleRow({ label, description, checked, onChange, disabled = false }: ToggleRowProps) { + return ( +
+ +
+ ); +} + 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 = { + 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) => { - 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) => { + setLocalMargins((previous) => ({ + ...previous, + [margin]: Number(event.target.value), + })); + }; + return ( 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]" >
- {!epub && !html &&
-
- -
- {/* Header Margin */} -
-
- Header - {Math.round(localMargins.header * 100)}% -
- -
+ {!html && ( +
+
+

Playback Flow

+

Control segment generation and lookahead while audio is active.

+
- {/* Footer Margin */} -
-
- Footer - {Math.round(localMargins.footer * 100)}% -
- -
+ updateConfigKey('skipBlank', checked)} + /> - {/* Left Margin */} -
-
- Left - {Math.round(localMargins.left * 100)}% -
- -
+ updateConfigKey('smartSentenceSplitting', checked)} + /> - {/* Right Margin */} -
-
- Right - {Math.round(localMargins.right * 100)}% -
- -
-
-

- Adjust margins to exclude content from edges of the page during text extraction (experimental) -

-
- updateConfigKey('viewType', newView.id as ViewType)} - > -
- - - {selectedView.name} - - - - - - - {viewTypeTextMapping.map((view) => ( - - `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' - }` - } - value={view} - > - {({ selected }) => ( - <> - - {view.name} - - {selected ? ( - - - - ) : null} - - )} - - ))} - - - {selectedView.id === 'scroll' && ( -

- Note: Continuous scroll may perform poorly for larger documents. -

- )} -
-
+
+ String(value)} + onChange={(value) => { + const next = clampSegmentPreloadDepth(value); + setLocalPreloadDepth(next); + void updateConfigKey('segmentPreloadDepthPages', next); + }} + /> -
} + String(value)} + onChange={(value) => { + const next = clampSegmentPreloadSentenceLookahead(value); + setLocalSentenceLookahead(next); + void updateConfigKey('segmentPreloadSentenceLookahead', next); + }} + /> - {!html &&
- -

- Automatically skip pages with no text content -

-
} - {!html && ( -
- -

- Merge sentences across page or section breaks -

-
- )} - {!epub && !html && ( -
-
- -

- Visual text playback highlighting in the PDF viewer -

-
-
- -

- Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'} -

-
-
- )} - {epub && ( -
-
- -

- Visual text playback highlighting in the EPUB viewer -

-
-
- -

- Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'} -

-
-
- )} - {epub && ( -
- -

- Apply the current app theme to the EPUB viewer background and text colors -

-
- )} + String(value)} + onChange={(value) => { + const next = clampTtsSegmentMaxBlockLength(value); + setLocalMaxBlockLength(next); + void updateConfigKey('ttsSegmentMaxBlockLength', next); + }} + /> +
+ + )} + + {!epub && !html && ( +
+
+

PDF Layout & Extraction

+

Set viewer mode and trim page edges before extraction.

+
+ +
+ +
+ {viewTypeTextMapping.map((view) => { + const active = selectedView.id === view.id; + return ( + + ); + })} +
+ {selectedView.id === 'scroll' ? ( +

Continuous scroll may perform poorly for very large PDFs.

+ ) : null} +
+ +
+

Text extraction margins

+

+ Exclude content near edges before sentence extraction. +

+
+ {(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => ( +
+
+ {margin} + {Math.round(localMargins[margin] * 100)}% +
+ +
+ ))} +
+
+
+ )} + + {!epub && !html && ( +
+
+

PDF Highlighting

+

Control playback highlighting behavior in PDF mode.

+
+ updateConfigKey('pdfHighlightEnabled', checked)} + /> + updateConfigKey('pdfWordHighlightEnabled', checked)} + /> +
+ )} + + {epub && ( +
+
+

EPUB Appearance

+

Apply app styling and playback highlighting in EPUB mode.

+
+ updateConfigKey('epubTheme', checked)} + /> + updateConfigKey('epubHighlightEnabled', checked)} + /> + updateConfigKey('epubWordHighlightEnabled', checked)} + /> +
+ )}
); diff --git a/src/components/reader/ReaderSidebarShell.tsx b/src/components/reader/ReaderSidebarShell.tsx index dbc9837..17ea545 100644 --- a/src/components/reader/ReaderSidebarShell.tsx +++ b/src/components/reader/ReaderSidebarShell.tsx @@ -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({

{title}

+ {subtitle ?

{subtitle}

: null}
{headerActions} diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 8049087..328b21c 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -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(); + 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(); + 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({ kind: 'idle' }); const [isClearing, setIsClearing] = useState(false); - const [clearError, setClearError] = useState(null); const abortRef = useRef(null); + const listRef = useRef(null); + const didAutoScrollOnOpenRef = useRef(false); const activeSettings = useMemo(() => ({ 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(); - const map = new Map(); - 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(); + 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(); - 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(); - 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('[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('[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 = ( <>
- {row && ( - - )} +
); })} )} + {state.kind === 'ready' && state.loadingMore && ( +
Loading more segments…
+ )} ); diff --git a/src/components/views/EPUBViewer.tsx b/src/components/views/EPUBViewer.tsx index 615ec0b..43f4798 100644 --- a/src/components/views/EPUBViewer.tsx +++ b/src/components/views/EPUBViewer.tsx @@ -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) {